String对象锁&使用String进行多线程同步操作

Java 字符串的使用以及特性就不再多说了,本次主要讲String.intern()方法。

# String 特性

Java中String类型是加上final修饰符的(常量类),一般存放于常量池中。但是相同的字符串对象却不一定相等的,Java会把编译期已确定的字符串存入常量池,但是对于运行时产生的字符串JAVA并不会再主动放入常量池。

String = "abc"; // 会存放进常量池
String = new String("abc") // 不会主动存进常量池

于是就有以下几个神奇的特性

    @Test
    public void test() {
        String constantPoolStr = "abc";
        String newStr = new String("abc");
        String joinStr = "ab" + "c";
        String joinStr2 = "ab" + new String("c");

        log.info("res1: " + (constantPoolStr == newStr)); // false
        log.info("res2: " + (constantPoolStr == joinStr)); // true
        log.info("res3: " + (constantPoolStr == joinStr2)); //false
        log.info("res4: " + (newStr == joinStr)); //false
        log.info("res5: " + (newStr == joinStr2)); //false
    }
可以见到只有第二个为真,其他均为假。这是因为`joinStr`在编译期已经编译成"abc"与`constantPoolStr`一样了。而其他 new 出来的对象因为都是在运行时确定的,于是会产生一个新的String对象,其内存地址与常量池中的"abc"完全不同,所以使用 == 对比均为 false 。这也是为何`String`对象需要使用`equal`的原因之一。

除了new 出来的String,还可以有很多像valueOfformat 等方法可以获得一个 String 对象,但只要根据以上原则就能够推出哪些字符串会启动时候就存在于常量池,哪些不会。

# 使用 String Key作为同步的特殊场景

有些时候为了在应用中做多线程的协作和互斥操作,需要使用的一个Key作为锁,比喻一个限制一个IP地址,对一个ID 的操作等……

如果使用Synchronization操作应该Synchronize一个什么对象呢,这个时候可能会发现无从操作,因为没有一个唯一的对象。每一次从数据库查询的都是一个新的对象,每一次从接口获取的都是一个新的对象,他们可以equal但却不能==,均不能拿来同步操作,如果将该操作改为同步方法太影响体验,因为其他Key访问该方法一律阻塞。在不依赖其他显式锁的情况下,这种时候 String 我认为可以成为担任锁的对象,因为 String 内容相等的对象是 可以 存在与常量池中而且唯一的,但是要注意上文提到的例子的陷阱,我们需要让 String 对象存在与常量池中,而且保证每次拿到的都唯一。

以下例子为 String 对象不唯一造成了线程安全问题。

    @Test
    public void test() {
        CompletableFuture
                .allOf(IntStream
                        .range(0, 100000)
                        .mapToObj(i -> CompletableFuture.runAsync(() -> {
                            String key = new String("key");
                            synchronized (key) {
                                integerList.add(1);
                            }
                        }))
                        .toArray(CompletableFuture[]::new))
                .whenComplete((res, err) -> {
                    if (null != err) err.printStackTrace();
                    logger.info("length: " + integerList.size());
                });

    }

    // 14:41:30.819 [ForkJoinPool.commonPool-worker-12] INFO  syn_demo.string.SynKeyTest - length: 95517

在一个非线程安全的列表并发操作,严重的情况会破坏数据结构抛出ArrayIndexOutOfBoundsException。解决问题需要使用到String.intern()方法,这是一个本地方法,看文档对该方法的解释。

   /**
    * ...
    * When the intern method is invoked, if the pool already contains a
    * string equal to this {@code String} object as determined by
    * the {@link #equals(Object)} method, then the string from the pool is
    * returned. Otherwise, this {@code String} object is added to the
    * pool and a reference to this {@code String} object is returned.
    * ...
    */
    public native String intern();

如果常量池存在该字符串,便返回常量池中的字符串引用,否则将该字符串存入常量池并返回引用。

我们来试一下第二种情况

    @Test
    public void test() {
        String newStr = new String("abc");
        log.info("res: " + (newStr == newStr.intern()));
        log.info("res: " + (new String("abc").intern() == new String("abc").intern()));
    }

    // 14:48:54.091 [main] INFO  syn_demo.string.StringTest - res: false
    // 14:51:12.124 [main] INFO  syn_demo.string.StringTest - res: true

返回的引用和原来的对象并不相等。但是两个intern方法的返回值却相等。可见intern方法可以返回常量池中的唯一引用。

我们开始对上述代码进行改造,我们继续使用new String模拟一个并发操作相同key的情景。

    @Test
    public void test() {
        CompletableFuture
                .allOf(IntStream
                        .range(0, 100000)
                        .mapToObj(i -> CompletableFuture.runAsync(() -> {
                            String key = new String("key").intern();
                            synchronized (key) {
                                integerList.add(1);
                            }
                        }))
                        .toArray(CompletableFuture[]::new))
                .whenComplete((res, err) -> {
                    if (null != err) err.printStackTrace();
                    logger.info("length: " + integerList.size());
                });

    }

    // 14:58:58.684 [ForkJoinPool.commonPool-worker-4] INFO  syn_demo.string.SynKeyTest - length: 100000

这次真正实现了同步操作,完成了开发需求,同时其他Key访问不会造成阻塞。

# 其他更好的方法?

有,而且还不少。

一开始想到给锁加一个标识就行了,但是加了标识之后还需要用一个容器保证锁的唯一性(类似常量池),既然如此其实可以直接使用并发容器的特性来进行操作。

并发容器本来就有些许特性可以辅助我们进行同步操作,比如add,putIfAbsent 之类,并发容器有很多并且使用于不同场景,不多介绍。

package syn_demo.queue;

import lombok.extern.slf4j.Slf4j;
import org.junit.Test;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutionException;
import java.util.stream.IntStream;

/**
 * @author a9043
 */
 @SuppressWarnings("RedundantStringConstructorCall")
@Slf4j
public class TestLockTest {
    private static final ConcurrentHashMap.KeySetView<String, Boolean> keySet =
            ConcurrentHashMap.newKeySet();

    private void lockOp() throws InterruptedException {
        String key = new String("key");
        if (!keySet.add(key)) return;
        log.info("lock success: " + key);
        Thread.sleep(1500); // doing something
        keySet.remove(key);
    }

    @Test
    public void test() throws ExecutionException, InterruptedException {
        CompletableFuture[] completableFutures = IntStream.range(0, 10)
                .mapToObj(i -> CompletableFuture.runAsync(() -> {
                    try {
                        lockOp();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }))
                .toArray(CompletableFuture[]::new);

        CompletableFuture
                .allOf(completableFutures)
                .get();
    }
    }

// 15:50:06.101 [ForkJoinPool.commonPool-worker-9] INFO  syn_demo.queue.TestLockTest - lock success: key

得到结果并发中只有一个成功了,而其他失败的也没有阻塞,取不到锁就会退出方法。

这其实是利用了ConcurrentHashMap.newKeySet()中的add方法,而该类是ConcurrentHashMap 的内部类,add方法调用了该 map 的putIfAbsent方法,从而保证了只有一个能成功添加,实现了互斥操作。 除了ConcurrentHashMap.newKeySet(),其实还有很多其他容器可以选择,比如CopyOnWriteSet()

# 测试一下

  • synchronized

    16:11:48.010 [ForkJoinPool.commonPool-worker-3] INFO  syn_demo.queue.TestLockTest - lock success: key
        16:11:49.513 [ForkJoinPool.commonPool-worker-8] INFO  syn_demo.queue.TestLockTest - lock success: key
        16:11:49.518 [ForkJoinPool.commonPool-worker-3] INFO  syn_demo.queue.TestLockTest - until: 1508000000
        16:11:51.014 [ForkJoinPool.commonPool-worker-8] INFO  syn_demo.queue.TestLockTest - until: 3007000000
        16:11:51.014 [ForkJoinPool.commonPool-worker-4] INFO  syn_demo.queue.TestLockTest - lock success: key
        16:11:52.515 [ForkJoinPool.commonPool-worker-4] INFO  syn_demo.queue.TestLockTest - until: 4508000000
        16:11:52.515 [ForkJoinPool.commonPool-worker-15] INFO  syn_demo.queue.TestLockTest - lock success: key
        16:11:54.016 [ForkJoinPool.commonPool-worker-15] INFO  syn_demo.queue.TestLockTest - until: 6009000000
        16:11:54.016 [ForkJoinPool.commonPool-worker-13] INFO  syn_demo.queue.TestLockTest - lock success: key
        16:11:55.517 [ForkJoinPool.commonPool-worker-13] INFO  syn_demo.queue.TestLockTest - until: 7510000000
    

    一个Key一千个并发 synchronized的效率已经不算太好的,因为所有线程都要等该key的操作。

    • ConcurrentHashMap.newKeySet()
    16:14:05.935 [ForkJoinPool.commonPool-worker-12] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-7] INFO  syn_demo.queue.TestLockTest - lock success: key
        16:14:05.935 [ForkJoinPool.commonPool-worker-13] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-4] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-14] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-5] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-1] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-3] INFO  syn_demo.queue.TestLockTest - until: 1000000
        16:14:05.935 [ForkJoinPool.commonPool-worker-6] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-11] INFO  syn_demo.queue.TestLockTest - until: 0
        16:14:05.935 [ForkJoinPool.commonPool-worker-2] INFO  syn_demo.queue.TestLockTest - until: 0
    

    而使用并发容器的add操作,几乎没有阻塞。

    # 结论

测试比较简单,没有在大量不同key和大量相同key并发下测试,不过依然可以得出一些结论。从性能上讲,对于key几乎不冲突的情况下,两者差异可以忽略,而且使用并发容器还需要另外的储存空间,但如果同一个key冲突数会比较多的话使用并发容器感觉更好。

不过从安全来说,synchronized方式同步字符串是有一定的危险性的,因为对于常量池中的字符串,不同包不同类的字符串,特别是使用了intern方法,都是一样的引用,不经意的使用了同一把锁,就会产生死锁的情况。所以如果使用该方法进行同步,可以加上自定义的标识符前缀,避免死锁。

在非特殊要求下,尽量不要使用String的内置锁进行同步,因为即使没有进行显式的intern,两个字符串对象仍然可能是同一个常量池字符串引用。