package com.thread.xgb;
public class UnsafeCounter {
public int count = 0;
public void add() {
count++;
}
public int get() {
return count;
}
}
编写一个简单的测试用例来验证它在多线程环境下是线程不安全的,这里用到了线程池和 J.U.C 包中的 CountDownLatch,使用线程池开启 100 个线程去执行 add() 累加操作,使用 CountDownLatch 的目的是为了保证打印最后结果的时候,100 个执行累加计数的线程已经先于主线程打印操作完成。关于线程池和 CountDownLatch 用法不太了解的读者,请自行查阅相关资料学习,这部分不是本文重点,不多赘述。
public static void main(String[] args) throws InterruptedException {
UnsafeCounter uc = new UnsafeCounter();
ExecutorService executorService = Executors.newCachedThreadPool();
// 设置 CountDownLatch 的计数器为 100,保证在主线程打印累加结果之前,100 个线程已经执行完累加
CountDownLatch cdl = new CountDownLatch(100);
for(int i = 0; i < 100; i++) {
executorService.execute(() -> {
uc.add();
// 每一个线程执行完累加操作,都将计数器减 1
cdl.countDown();
});
}
// 主线程等待,直到 cdl 的计数器为0
cdl.await();
System.out.println("计数器执行完100次累加后值为:" + uc.get());
}
如果是线程安全的话,那么控制台打印结果应该为 100,我们执行 5 次观察一下打印结果。
没有一次结果是正确的,至此我们验证了这种实现计数器方式的在并发环境下是线程不安全的。
我们使用 javac UnsafeCounter.java
指令来编译文章一开始所给出的源码。
得到了对应的字节码文件后,我们再使用 javap -c UnsafeCounter.class
指令来反编译刚才得到的字节码文件,查看编译之后代码的具体操作指令。主要来看一下 add 方法的指令。
public void add();
Code:
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return
简单的解释一下,add 方法内的 count++ 这行代码实际上需要执行三个指令:
这也就是线程不安全的原因所在,因为 count++ 操作不具备原子性。
原子性操作指的是不可被中断的一个或一系列操作。下图描述了为什么非原子操作造成了这里的线程不安全问题
假设有两个线程去执行 add 操作,此时 count 是 0,那么存在上图中的这种可能,在线程 A 执行这三步的过程中 cpu 时间片耗尽线程 B 被调度,此时由于内存中 count 的值仍为 0(因为线程 A 的操作结果还未刷新到内存中),所以线程 B 仍是在 0 的基础上执行自增,所以导致最终内存中的 count 是 1,而不是 2.
一、使用 volatile
package com.thread.xgb;
public class UnsafeCounter {
public volatile int count = 0;
public void add() {
count++;
}
public int get() {
return count;
}
}
使用 volatile 修饰成员变量 count,能解决 count++ 线程不安全的问题吗?
volatile 在并发编程中保证了共享变量的 “可见性”,可见性的意思是当一个线程修改一个共享变量时,另外一个线程立即能读到这个修改的值。
在这里不得不另提及一个额外的概念:JMM(Java Memory Model,Java 内存模型)。
这里简单的说一下,它屏蔽了各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,JMM 将内存划分为主内存和工作内存。.
如下图所示
了解了 volatile 和 JMM 的概念之后,我们重新来看反编译字节码文件后的那三条指令(注:使用 volatile 修饰 count 变量,重新执行javac UnsafeCounter.java
与 javap -c UnsafeCounter.class
得到的结果是一样的,不过在内存语义上有了很大的区别。)
为了方便查看,这里再贴一次反编译后的代码:
public void add();
Code:
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return
我们从 JMM 的角度来解读 getfield、iadd、putfield 这三条命令,并对比不用 volatile 修饰与使用 volatile 修饰的区别。
首先是未使用 volatile 修饰:
现在来看一下使用 volatile 修饰后,它具有了更强的内存语义:
但是这样做能保证线程安全吗?当然不能,因为它还是保证不了操作的原子性!虽然使用 volatile 修饰后能保证线程读取到最新的 count 值,并且修改后立即刷新回主内存,但依然无法改变这三个指令结合起来不是原子操作的事实。
比如,在线程 A 执行完 getfield 指令之后,发生了 cpu 调度,此时线程 B 开始执行,那不还是跟原来一样吗。
二、使用 synchronized 同步代码块
package com.thread.xgb;
public class UnsafeCounter {
public int count = 0;
public void add() {
synchronized(this){
count++;
}
}
public int get() {
return count;
}
}
使用 synchronized 进行加锁可以保证该计数器线程安全,因为只有一个线程能取得锁去执行 count++,其他线程必须等待锁的获取。
就拿本文中提及的计数器举例,实现原子操作,保证线程安全的手段主要有两个:CAS(乐观锁) 、互斥同步(悲观锁)。
一、CAS(使用 J.U.C 包中提供的原子类)
将原来线程不安全的计数器改成下述的实现方式,利用 J.U.C 包中的原子类:AtomicInteger
public class SafeCounter {
private AtomicInteger count = new AtomicInteger();
public void add() {
count.incrementAndGet();
}
public int get() {
return count.get();
}
}
incrementAndGet 方法就是使用 CAS 操作保证的原子性。
二、互斥同步
就是我们在讨论 volatile 和 synchronized 的时候,使用同步代码块的情况。它也能保证原子性,这里不再赘述。