目录
1 多线程带来的风险-- 线程安全
1.1 线程不安全问题
class Counter{
private int count = 0;
public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
设计一个代码,两个线程针对同一个变量,各自自增5w次,预期结果是10w次;
运行程序后,会发现预期结果是10w,实际结果缺像个随机值一样,每一次结果还不一样。实际结果和预期结果不相符,就是bug。这就是由多线程引起的bug ,也就是线程安全问题。
本质上是因为线程之间的调度顺序是不确定的
public void add(){count++; }
代码中的 count++ 操作,本质上是三个cpu指令构成:
1 load 把内存中的数据读取到cpu寄存器中;
2 add 就是把寄存器中的值,进行 +1 运算;
3 save 把寄存器中的值写回到内存中;
t1 和 t2 分别代表两个线程,两个线程调用count.add方法,由于count++分为三个原子步骤(原子性代表一条语句或者是一段语句时为不可分割的整体),CPU的核心在执行的时候是按照原子步骤走的,所以线程调度的时候,CPU核心不会一次性将count++操作执行完。在多线程的环境下,调度顺序的不确定,两个线程的count++操作实际的指令排列顺序就有很多的可能性。下列是部分举例:
拿上图第一种排列方式计算一下,初始情况下count = 0;假设 t1 和 t2 分别运行在不同的cpu核心上 ,t1线程执行load,把数据0从内存中读取到 cpu1上,t2也执行load,把数据0也读到cpu2上,接着 t2 执行add自增操作,数据变为1 ,然后save保存写回到内存中,现在内存中的数据为1。然后 t1 执行add操作,自增为1同样save写回内存中,但是内存中的数据已经是1了,自增了两次但最终结果仍然是1,bug就出现了,其中一次自增的结果,被另一次给覆盖了。
这就是多线程带来的风险--线程安全问题。
1.2 线程不安全的原因
导致线程不安全的原因有:
1 抢占式执行(最主要的,是罪魁祸首);
2 多个线程修改同一变量;
3 不是原子性的;
4 内存可见性,引起的线程不安全;
5 指令重排序,引起的线程不安全;
1.2.1 多个线程修改同一个变量
上述的线程不安全的代码中,涉及到多个线程针对count变量进行修改,此时count是一个多个线程都能访问到的“共享数据”。
1.2.2 不是原子性的
一条java语句不一定是原子的,也不一定只是一条指令。
比如count++,其实就是由三步操作组成的:load add save.
不保证原子性会给多线程带来什么问题。
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。
1.2.3 内存可见性,引起的线程不安全
可见性指,一个线程对共享变量值得修改,能够及时地被其他线程看到。
首先要了解一下java的内存模型。
java内存模型(JMM):java虚拟机规范中定义了java内存模型。目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。
1 线程之间的共享变量储存在主内存中。
2 每一个线程都有自己的“工作内存”。
3 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据。
4 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存。
由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的副本,此时修改线程1的工作内存中的值,线程2的工作内存不一定会及时变化。这个时候代码中就容易出现问题。
这里解释一下两个问题:
1 为什么设置这么多的工作内存?
实际上并没有这么多“内存”,这只是JAVA规范中的一个术语,属于抽象叫法。所谓的主内存才是真正硬件角度的内存,而所谓的“工作内存”则是指CPU的寄存器和高速缓存。
2 为什么会频繁拷贝?
因为CPU访问自身寄存器的速度以及高速缓存的速度,远超访问内存的速度。比如需要连续读取一个变量的值,如果每次都从内存读,速度是比较慢的,但是如果只是第一次从内存读,读到的结果缓存到CPU的寄存器中,那么后面的读数据就不必直接访问内存了,效率就提高了。
1.2.4 指令重排序,引起的线程不安全
假设一段代码: 1 .去快递站取快递A 2 .返回家 3 .去快递站取快递B
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如 按 1- 3 - 2的方式执行,这样既能保证结果不变,效率也变高,这种就称为指令重排序。
1.3 解决线程安全问题的方法
1.3.1 synchronized 锁
前面代码中,如何解决线程安全问题,可以观察到问题在count++身上,三种操作产生了多种排列顺序,我们可以把count++操作整体变为原子的,这样就能保证 t1 线程在调用count++的时候能完整的执行完。
使用synchronized 关键字对其进行加锁操作,保证“原子性”的效果。
锁的核心操作有两个:
1 加锁
2 解锁
一旦某个线程加锁之后,其他线程也想加锁,就不能直接加上,就需要阻塞等待
一直等到拿到锁的线程释放锁为止。
public void add(){
synchronized (this){
count++;
}
class Counter{
private int count = 0;
public void add(){
synchronized (this){
count++;
}
}
public int getCount(){
return count;
}
}
//线程不安全
public class ThreadDemo13 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
Synchronized(this) ,( )里面是锁对象,表示在针对哪个对象加锁,如果两个线程,针对同一个对象加锁,此时就会出现“锁竞争” 。如果两个线程,针对不同对象加锁,就不会存在锁竞争,各自获取各自的锁即可。
上述代码中,两个线程是在竞争同一个锁对象,就会产生锁竞争 。假设 t1 率先调用到count++操作,成功加上了锁,这样能完整地执行整个count++操作,如果此时 t2 执行到了count++,也想对同一个对象中count变量进行自增操作,由于t1已经率先上锁了,就只能阻塞等待,等到 t1 自增完毕,t2才能进行自增。此时就可以保证t2的load一定在t1的save之后,此时计算的结果就是线程安全的了。
这样代码执行完就能达到预期效果了。进入synchronized修饰的代码块,相当于加锁,退出synchronized修饰的代码块,相当于解锁。
synchronized的特性:
互斥:synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象synchronized就会阻塞等待。可以理解为:上单间厕所问题,ABC三人,A先抢到厕所,并对厕所(就是对象)进行上锁,这时B和C也想上厕所此时只能阻塞等待,当A解锁出来,B和C两人就会重新竞争厕所。
加锁,本质上是把并发的变成了串行。
可重入:后续会再介绍。
synchronized的写法
1 直接修饰普通方法:锁的synchronizedDemo对象 ,如果直接给方法使用synchronized修饰,就相当于以 this 为锁对象。
public class SynchronizedDemo {
public synchronized void methond() {
}
}
2 修饰静态方法 :锁的synchronizedDemo类的对象,如果synchronized修饰静态方法(static)此时就不是给 this 加锁了,而是给类对象加锁,当一个线程A调用实例对象的非静态 synchronized方法,而线程B调用实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥对象。
public class SynchronizedDemo {
public synchronized static void method() {
}
}
3 修饰代码块:明确指定锁哪个对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
比较常见的还是使用第三种,可以手动指定一个锁对象。
1.3.2 volatile关键字
使用volatile修饰的变量,能够保证“内存可见性”。
public class ThreadDemo14 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag == 0){
}
System.out.println("循环结束 t1 结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述的预期结果:t1线程通过flag == 0 作为条件进行循环,初始情况,将进入循环。t2 通过控制台输入一个整数,一旦用户输入了非0的值,此时t1的循环就会立即结束,从而 t1 线程退出。
可以发现输入非0的值之后,t1 线程并没有退出,仍然在执行中。导致该bug的原因就是由内存可见性引起的线程不安全。
上述代码中,while()循环flag == 0 ,这里会有两步操作,一个是load读取操作,一个是cmp操作(比较寄存器里的值是否是0),这里两个操作,load的时间开销远远高于cmp,读内存虽然比读硬盘来得快,但是读寄存器,比读内存更快。计算机一秒钟执行上亿次,编译器就会发现: 1 load的开销很大 2 每次load的结果都一样。此时编译器就做了一个大胆的操作,把load给优化掉了,只有第一次执行load才真正执行了,后续循环都是cmp,不进行load,相当于是复用之前寄存器中的load过的值。
编译器优化的手段,是一个非常普遍的事情。
所谓的内存可见性,就是多线程环境下,编译器对于代码优化,产生了误判,从而引起了bug,进一步导致了代码的bug。
此处处理方式,就是让编译器针对这个场景暂停优化。
Volatile 关键字
被该词修饰的变量,此时编译器就会禁止上述优化
能够保存每次都是从内存重新读取数据。
上面介绍内存可见性时介绍了,直接访问工作内存(CPU寄存器或者CPU的缓存),速度非常快,但是可能出现数据不一致的情况。加上volatile,强制读写内存,速度是慢了,但是数据变得更准确了。
volatile public static int flag = 0;
public class ThreadDemo {
volatile public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag == 0){
}
System.out.println("循环结束 t1 结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性。
volatile还有一个效果,就是禁止指令重排序。指令重排序也是编译器优化的策略,调整了代码执行的顺序,让程序更加高效,前提得是保证整体逻辑不变。谈到优化,都得保证调整之后的结果和调整之前的结果是不变的,单线程下容易保证,如果是多线程,就不好说了。