多线程安全问题
- 线程安全问题的引入
- 案例引入
- 多线程指令排序问题
- 线程不安全的原因
- 解决线程不安全的方法
- 锁的引入
- 上锁和解锁过程
- 一个简单的锁Demo
- 对这个案例进行几次修改
- 总结
线程安全问题的引入
在前面的博文中,我们了解到通过Thread.join()的方法让线程进入等待,能够在一定程度上解决线程抢占式执行的问题。回忆点这里
那么由于多线程代码而导致的bug,这样的问题就是线程安全问题。
案例引入
在下面的代码中,我希望执行之后得到count=100000的结果。
private static long count;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {count++;}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count:"+count);}
结果如图:通过结果,我们每次运行得到的答案都是不一样的。
多线程指令排序问题
在上面的demo中,执行count++的指令并不只有一条这么简单。它可以分为三个步骤:(1) load操作: 读取内存中count的数值,保存到cpu寄存器中。 (2) add操作: 将寄存器中的count+1 (3) save操作:将寄存器中count的数值存放回内存中。
看似三条指令的简单操作,在多线程并发执行中却容易导致许多问题。
在多线程随即调度的执行状况下,两个线程的指令执行相对顺序可能就会存在多种可能,下面我列出几种可能性。
在上面的顺序中,我们可以理解为 在第一次t1线程的count++之后,count=1的值存放于寄存器中,接下来t2线程count++的时候,load指令下读取的数值为count=0,之后save操作后count=1,最后执行t1线程的save后值不变。因此执行了两次count++操作后,count的值只加了一次,出现了覆盖现象。
我们可以不断扩大到其他状况,如t1线程辛辛苦苦加了100次,但是t2线程最终存放count=1将值覆盖了。
线程不安全的原因
- 线程在系统中是随即调度,抢占式执行的。
- 多个线程同时修改同一个变量(参考上述例子)
- 线程对变量进行修改操作,非原子指令
- 内存可见性问题
- 指令重排序问题
解决线程不安全的方法
对于原因2,我们可以通过join等方式防止这种情况的发生,但这样做并不普适,属于少有的情况。
锁的引入
对于案例中的count++操作,我们清楚它不是一个原子操作,因此,程序猿想出来了一个办法:将上述的一系列“非原子”操作打包成一个“原子”操作。这样就能够避免线程不安全的问题。
基于这样的背景,锁被创建出来了。
上锁和解锁过程
假设存在两个线程t1和t2,
(1)上锁:我们首先给t1加上锁(lock),t2也尝试加同一把锁,那么这时候t2线程就会阻塞等待,在Java中该线程处于Blocked状态。
(2)解锁:当线程t1执行完锁住的部分后,线程t1解锁,接着由线程t2通过锁竞争拿到该锁(lock),加锁成功,t2线程转变为Runnable状态。
通过锁的存在,使得线程之间存在互斥的关系。在两个线程之间尚且都要通过锁竞争,而存在多个线程的情况下自然也要通过竞争的方式占据锁。这里必须要明确一个条件:线程之间竞争的必须要是同一把锁。
一个简单的锁Demo
通过下面的代码块进行简单的解释。
- 首先new一个Object类的对象object1.我们要把这个对象作为锁。看到这里我们就可以清楚,锁不必是某种特定的类,他只是一个标识,只是一个对象即可。
- 在这段案例中,存在main、t1、t2三个线程,main线程十分简单,通过Thread.join()的方法等待t1和t2两个线程运行结束之后打印出结果即可。
- 在线程t1和t2中,在for循环中,我们可以看到synchronized (object1)。其中synchronized就是锁的关键字。在t1和t2都使用了这段语句,即在进行count++操作之前,需要进行锁竞争,只有拥有锁的一方才可以进行count++操作。
- 在这段代码块中,t1和t2在for循环的时候是并行执行的,而在锁竞争的时候是串行执行的。这样计算下来比单线程所花费的时间要少许多。
private static int count;
public static void main(String[] args) throws InterruptedException {Object object1 = new Object();Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (object1) {count++;}}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (object1) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = "+count);}
运行结果如下图:
对这个案例进行几次修改
(1)接下来我们可以考虑,当锁对象为两个:object1和object2时,两个线程分别竞争两个不一样的锁,会出现什么情况
结果如下:
通过这个改变,我们可以理解不同的锁对象之间不存在互斥关系,因此二者之间也就不会发生锁竞争。
(2)将synchronized放在for循环外面的情况
在这种条件下,意味着当t1或t2某一个线程拿到这把锁之后,只有等循环结束以后才能释放了,很明显这样的情况所花费的资源甚至多于单线程。
Thread t1 = new Thread(()->{synchronized (object1) {for (int i = 0; i < 50000; i++) {count++;}}});Thread t2 = new Thread(()->{synchronized (object1) {for (int i = 0; i < 50000; i++) {count++;}}});
(3)在下面的代码中,设计Counter类进行add和get操作,在上面的代码中,我们已经知道锁对象只是一个标识,不关心它是怎样的存在,因此在这里,我们大胆的把counter对象作为锁对象。
class Counter {public int count;public void add() {count++;}public int getCount() {return count;}
}
public class Demo3 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (counter) {counter.add();}}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (counter) {counter.add();}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count = "+counter.getCount());}
}
运行结果如下图:
(4)如果我们把锁加到add()方法中,我们通过this来指代对应的对象,这样做的情况是当多个线程调用该方法的时候,如果使用的是同一个对象会进行竞争,如果是不同对象的话则不会进行竞争。
同含义的写法为:synchronized public void add()
class Counter {public int count;public void add() {synchronized(this){count++;}}public int getCount() {return count;}
}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();}});
(5)对静态方法加锁
与(4)不同的是,加锁的对象为Counter这个类对象,因此如果多个线程调用func方法,则这些线程之间都会进行锁竞争。
//第一种写法
public static void func(){synchronized (Counter.class) {//func}
}
//第二种写法
synchronized public static void func(){//func
}
总结
对于锁的概念需要逐渐深入,在本文中讲解了锁引入的原因以及锁的几种写法。
本文中使用的源码请戳此处