目录

当synchronized关键字作用在字符串上的一些问题

概述

现有需求如下,每个用户(租户ID:用户ID)执行某个操作时,对其进行计数,在考虑高并发的情况下,不会产生计数错误问题。

实现

看到需求后,脑中迅速想好了一个简单思路:
将用户(租户ID:用户ID)作为 KEY, 在执行查询和更新时,利用关键字synchronized锁之即可,下面是测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class Main {
    private static final Map<String, Integer> COUNT_MAP = Maps.newHashMap();

    public static void main(String[] args) {
        final List<Thread> threads = Lists.newArrayList();

        // 模拟 3 个用户
        for (int i = 0; i < 3; i++) {

            // 每个用户并发 100 次操作计数器
            for (int j = 0; j < 100; j++) {
                int userId = i;
                Thread thread = new Thread(() -> {
                    final String key = "providerId:" + userId;

                    int count = get(key);
                    set(key, count + 1);
                });
                threads.add(thread);
                thread.start();
            }
        }


        threads.forEach(thread -> {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        System.out.println(COUNT_MAP);
    }

    static Integer get(String key) {
        synchronized (key) {
            Integer count = COUNT_MAP.get(key);
            return null == count ? 0 : count;
        }
    }

    static void set(String key, Integer count) {
        synchronized (key) {
            COUNT_MAP.put(key, count);
        }
    }
}

然而运行了几次后,发现结果差强人意,难道是synchronized关键字没有起作用?

1
2
3
4
{providerId:2=98, providerId:1=97, providerId:0=98}
{providerId:0=100, providerId:1=99, providerId:2=100}
{providerId:2=100, providerId:0=100, providerId:1=99}
{providerId:0=100, providerId:1=100, providerId:2=100} // 最后一次总算是成功了

问题分析

回头看看代码,不难发现,问题出现在synchronized (key) 这行代码上, key 在这里是一个字符串,每次构建 key 时,其实都是一个全新的对象,这当然锁不住啦!

解决方案

对以上代码进行稍加改造,就可以解决我们的问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static Integer get(String key) {
    synchronized (key.intern()) {
        Integer count = COUNT_MAP.get(key);
        return null == count ? 0 : count;
    }
}

static void set(String key, Integer count) {
    synchronized (key.intern()) {
        COUNT_MAP.put(key, count);
    }
}

这是为何?
我们知道,每个用户在 100 次的并发操作中,key 的值是一样的,但是架不住人家要比较 key 的内存地址啊!

调用String#intern()便是解决了这个问题,那么这个函数到底有何魔力呢?来,点进去看看源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/** 
 * Returns a canonical representation for the string object. 
 * <p> 
 * A pool of strings, initially empty, is maintained privately by the 
 * class <code>String</code>. 
 * <p> 
 * When the intern method is invoked, if the pool already contains a 
 * string equal to this <code>String</code> object as determined by 
 * the {@link ##equals(Object)} method, then the string from the pool is 
 * returned. Otherwise, this <code>String</code> object is added to the 
 * pool and a reference to this <code>String</code> object is returned. 
 * <p> 
 * It follows that for any two strings <code>s</code> and <code>t</code>, 
 * <code>s.intern() == t.intern()</code> is <code>true</code> 
 * if and only if <code>s.equals(t)</code> is <code>true</code>. 
 * <p> 
 * All literal strings and string-valued constant expressions are 
 * interned. String literals are defined in section 3.10.5 of the 
 * <cite>The Java™ Language Specification</cite>. 
 * 
 * @return  a string that has the same contents as this string, but is 
 *          guaranteed to be from a pool of unique strings. 
 */  
public native String intern();

根据javadoc描述,如果常量池中存在当前字符串, 就会直接返回当前字符串引用. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回其引用,这样内存地址不就一样了嘛,哈哈。

警告

上面的改造方法虽然简单,但是坑很大,需要注意:
String#intern()在jdk6里问题不算大,会在perm里产生空间,如果perm空间够用的话,不会导致频繁Full GC,
但是在jdk7里问题就大了,会在heap里产生空间,而且还是老年代,如果对象一多就会导致频繁Full GC!

因此,请慎用。

1
2
### 查看 gc
jstat -gcutil $(jps -l | grep keyword | awk -F  '{print $1}') 5000

再改造

既然String#intern()如此危险,那么还有替代的解决方法吗?当然有:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private static final Interner<String> mLockKeyPool = Interners.newWeakInterner();
static Integer get(String key) {
    synchronized (mLockKeyPool.intern(key)) {
        Integer count = COUNT_MAP.get(key);
        return null == count ? 0 : count;
    }
}

static void set(String key, Integer count) {
    synchronized (mLockKeyPool.intern(key)) {
        COUNT_MAP.put(key + "", count);
    }
}

很好,Google Guava帮我们解决了这一问题,使用Interners.newWeakInterner()即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/**
 * Returns a new thread-safe interner which retains a weak reference to each instance it has
 * interned, and so does not prevent these instances from being garbage-collected. This most
 * likely does not perform as well as {@link ##newStrongInterner}, but is the best alternative
 * when the memory usage of that implementation is unacceptable. Note that unlike {@link
 * String#intern}, using this interner does not consume memory in the permanent generation.
*/
@GwtIncompatible("java.lang.ref.WeakReference")
public static <E> Interner<E> newWeakInterner() {
    return new WeakInterner<E>();
}

注意看 javadoc 的最后一句,【 注意,与String#intern()不同,使用这个interner不会消耗永生代中的内存 】这下放心了,至于原理,下次再分析吧!

评论