1. 背景
最近在重构代码的时候,偶然遇到了一些并发问题。比如说:一些全局的唯一key维护在内存中,根据时间戳来生成的key。但是既然是全局唯一key那么就可能会有并发场景下获取key的方法,会有可能同时掉到该方法,则生成的key就有可能会重复。于是我做了一次简单的重构。具体代码如下:
public class BatchNOUtil {
private static AtomicLong atomicBatchNo;
public static Long genBatchNo() {
Long now = Long.valueOf(OffsetDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));
if(Objects.isNull(atomicBatchNo)){
atomicBatchNo = new AtomicLong(now);
return now;
}
if(atomicBatchNo.longValue() >= now) {
return atomicBatchNo.incrementAndGet();
} else {
atomicBatchNo.set(now);
return now;
}
}
}
//测试代码如下
public static void test(){
int i = 0;
while(i < 10) {
Thread thread = new Thread(() ->_getBatchNo(), "thread" + i++);
thread.start();
}
}
private static void _getBatchNo(){
Long no = BatchNOUtil.genBatchNo();
System.out.println(Thread.currentThread().getName() + " no: " + no);
}
我使用支持并发的AtomicLong
来做全局唯一key,来解决并发问题带来的可能key的重复。但是实际上并不能解决问题,具体的结果如下:
thread3 no: 20180503214710
thread4 no: 20180503214711
thread0 no: 20180503214710
thread6 no: 20180503214709
thread2 no: 20180503214709
thread5 no: 20180503214709
thread1 no: 20180503214709
thread9 no: 20180503214709
thread8 no: 20180503214709
thread7 no: 20180503214709
可以看出并没有解决问题,还是又很多大量的key重复了。问题的根源就是虽然AtomicLong
自身是线程安全的,但是BatchNOUtil.genBatchNo()
方法不是同步的同一时刻仍然会有可能多线程同时获取到相同的时间戳now
(而且这里的时间戳使用的最精确才到秒),就会导致重复的key产生。
2. 解决
可以使用synchronized
同步关键词来修饰BatchNOUtil.genBatchNo()
方法,也就是给该方法加上一把互斥锁,所有线程如果要访问该方法就需要先获取锁,执行完该方法再释放锁。加上锁后执行的结果如下:
thread0 no: 20180503220051
thread9 no: 20180503220052
thread8 no: 20180503220053
thread7 no: 20180503220054
thread6 no: 20180503220055
thread5 no: 20180503220056
thread4 no: 20180503220057
thread3 no: 20180503220058
thread2 no: 20180503220059
thread1 no: 20180503220060
3. 延伸
synchronized
同步关键词可以用来修饰一个方法,也可以用来修饰一段代码块。所以上面的问题也可以用下面的方法解决:
synchronized(BatchNOUtil.class) {
...
//BatchNOUtil.genBatchNo的逻辑代码
}
当修饰一段代码块的时候(如上),则当前代码块线程同步。所有线程如果要访问该代码块,必须要获取一把类级别
的互斥锁,然后才能访问,否则就堵塞等待。
我们再看下一个例子:
public class ThreadTest implements Runnable{
private static int count = 0;
@Override
public void run() {
synchronized(this){
int i = 0;
while (i < 5) {
System.out.println(Thread.currentThread().getName() + " i=" + (i++) + ", count=" + (count ++));
}
}
}
public static void main(String[] args) {
Thread ta = new Thread(new ThreadTest(), "threadA");
Thread tb = new Thread(new ThreadTest(), "threadB");
ta.start();
tb.start();
}
}
执行的结果如下:
threadA i=0, count=0
threadB i=0, count=1
threadB i=1, count=3
threadB i=2, count=4
threadB i=3, count=5
threadB i=4, count=6
threadA i=1, count=2
threadA i=2, count=7
threadA i=3, count=8
threadA i=4, count=9
可以看出好像上面的synchronized
同步关键词并没有起到作用,但是实际上不是的,因为这时候synchronized
修饰的是一段代码块,但也是基于对象层面的。也就是说必须相同对象才会有互斥,如果对象不相同则是不会有互斥锁。简单把上面的main方法修改如下即可:
ThreadTest t1 = new ThreadTest();
Thread ta = new Thread(t1, "threadA");
Thread tb = new Thread(t1, "threadB");
ta.start();
tb.start();
也可以把上面的修饰改成对class层面的修饰(类级别锁
),synchronized(ThreadTest.class){}
这个时候同步的时候就是对ThreadTest.class
的静态资源加上互斥锁,由于count只有一次调用count++
是原子操作,所以也是可以解决并发问题的。
如果我们给ThreadTest
再加上两个计数方法count1和count2如下:
public void count1(){
synchronized(this){
int i = 0;
while (i < 5) {
System.out.println(Thread.currentThread().getName() + " i=" + (i++) + ", count=" + (count ++));
}
}
}
public void count2(){
int i = 0;
while (i < 5) {
System.out.println(Thread.currentThread().getName() + " i=" + (i++) + ", count=" + (count ++));
}
}
public static void main(String[] args) {
ThreadTest t1 = new ThreadTest();
Thread ta = new Thread(() -> t1.count1(), "threadA");
Thread tb = new Thread(() -> t1.count2(), "threadB");
ta.start();
tb.start();
}
这时候执行的结果如下:
threadB i=0, count=0
threadA i=0, count=0
threadA i=1, count=2
threadA i=2, count=3
threadA i=3, count=4
threadA i=4, count=5
threadB i=1, count=1
threadB i=2, count=6
threadB i=3, count=7
threadB i=4, count=8
可以看出虽然两个线程所用的对象是同一个对象,但是调用的两个方法有一个加了锁另一个没有加锁。也就是说,当线程A获取到该对象的互斥锁且未释放的情况下,线程B仍然可以对该对象的非同步的方法进行访问。当我们给count2也加上同步修饰的时候,就不会有并发问题了线程A和B就会同步的执行。这就说明了同一对象的所有同步代码都是互斥的,在执行它们前所有线程都要获取一把相同的互斥锁,否则只能阻塞等待。而同一对象的同步代码与非同步代码是没有互斥关系的。
4. 实现
通过反编译上面的例子ThreadTest.run
方法可以看出互斥锁是通过monitor
来实现的,在JVM执行到同步代码块开始的时候通过monitorenter
来尝试获取对象的互斥锁,如果获取不到则阻塞当前线程并等待,在monitorexit
的地方会释放互斥锁。这时候阻塞的线程就可以获取到互斥锁从而进入到同步代码块中。
public void run();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: iconst_0
5: istore_2
6: iload_2
7: iconst_5
8: if_icmpge 68
11: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
14: new #3 // class java/lang/StringBuilder
17: dup
18: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
21: invokestatic #5 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
24: invokevirtual #6 // Method java/lang/Thread.getName:()Ljava/lang/String;
27: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
30: ldc #8 // String i=
32: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
35: iload_2
36: iinc 2, 1
39: invokevirtual #9 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
42: ldc #10 // String , count=
44: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
47: getstatic #11 // Field count:I
50: dup
51: iconst_1
52: iadd
53: putstatic #11 // Field count:I
56: invokevirtual #9 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
59: invokevirtual #12 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
62: invokevirtual #13 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
65: goto 6
68: aload_1
69: monitorexit
70: goto 78
73: astore_3
74: aload_1
75: monitorexit
76: aload_3
77: athrow
78: return
5. 总结
synchronized
修饰的锁是对象级别的即synchronized(obj)
,这个时候该实例对象的所有synchronized方法块或者方法都是互斥的。即他们共享同一把锁。但是同一实例对象的非synchronized方法是不需要进行加锁就可以访问的。例:有一个类T,其有两个synchronized方法a,b和一个普通方法c,这时候线程A访问t.a(),就会先获的互斥锁,然后执行a(),在执行的时候线程B不能访问t.a()、t.b()。因为这时候A持有锁,直到A释放了锁。但是线程B可以访问t.c()。- 相同的类的不同实例可以同时获取对象级别的锁,因为它们不属于同一实例,也就没有互斥。
synchronized
修饰的锁是类级别的即synchronized(T.class)
这时候该类的所有实例共享同一把锁,但是synchronized
也只能作用于该类的所有静态资源。