1. 线程安全
1.1 线程安全的概念
如果多线程环境下代码运行的结果不符合我们的预期,则我们说存在线程安全问题,即程序存在bug,反之,不存在线程安全问题.
1.2 线程不安全的原因
我们下面举出一个线程不安全的例子:我们想要在两个线程中对count进行++操作
public class Demo9 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {count++;}});Thread thread1 = new Thread(()->{for (int i = 0; i < 50000; i++) {count++;}});thread.start();thread1.start();thread.join();thread1.join();System.out.println(count);}
}
运行结果如下:
但是这里我们预期的结果是100000,这里我们看到,实际结果和预期结果相差甚远,这便是产生了线程安全问题,使得程序出现了bug,我们要想解决上述的bug,我们必须先了解清楚bug产生的原因.
- 线程调度是随机的
这是线程安全问题的罪魁祸首
随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数.
程序猿必须保证在任意执行顺序下,代码都能正常工作.
某个线程在执行指令的过程中,当他执行任何一个指令的时候,都有可能被其他线程抢占走CPU. - 修改共享数据
多个线程同时修改同一个变量.上面的代码中,就都是针对count进行修改. - 原子性
在前面,我们有给大家提到过事务的原子性,大家还记得我们的助教迪卢克姥爷吗?
在这里,多线程的原子性其实和事务的原子性大相径庭.我们在这里首先要理解什么是多线程中的原子性:
有请助教:达达利亚,钟离
达达利亚和钟离都到了一台ATM机前来取钱,现在每一台ATM机前都有一个门,一把锁,当达达利亚进去之后,门就会自动上锁,这样钟离便不会对达达利亚取钱的过程造成干扰,在达达利亚取完钱之前,钟离只可以在外面排队等待,在达达利亚取完钱之后,钟离才可以进入.也就是在tread线程对count进行修改的时候,tread1线程不可以对tread修改count的过程进行干扰,这便保证了原子性.反之如果钟离对达达利亚取钱的过程造成了干扰,这便不保证原子性.
一条Java语句不一定是原子的,也不一定是一条指令:
我们回到线程这里,那么如果拿上面这个存在线程安全问题的代码(不保证原子性的代码),那么他的底层原理是什么样子的呢:
- 首先tread和tread1同时读到count=0
- tread线程对count进行++之后放入内存之后,count变为1
- tread1对线程进行++之后,对上一个count=1的值进行了覆盖,count还是1.
- 这便会引起bug
- 内存可见性
- 指令重排序
后续介绍
1.3 解决线程安全问题
要想解决线程安全问题,我们必须要从原因来入手:
- 从原因一入手:这是多线程已有的特性,无法干预.
- 从原因二入手:这是一个切入点,**但是不普适,只针对特殊的场景可以做到,**比如String把变量设置为不可变对象,就是为了保证线程安全问题.在对上一个String进行修改的时候,其实在底层又new了一个新的String,修改的实际上不是同一个变量.
- 从原因三入手:这是一个普适性比较高的切入点,我们想象,我们是否也可以有一把向ATM机那样的锁,来保证线程的原子性呢,答案是有.我们可以使用synchronized关键字来对线程进行上锁.通过上锁操作来把非原子的操作打包为一个原子的操作.保证tread线程对count计算的结果写入内存中在tread1线程读取内存中的count之后,使得它们呈现串行化执行.
- 从原因四和五入手,后续介绍.
1.4 synchronized关键字—>监视器锁
为了解决上述线程安全问题,我们使用synchronized对上述代码的线程进行加锁:
public class Demo11 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object o = new Object();//锁对象Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o) {//线程上锁count++;}}});Thread thread1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o){//拿到的都是o锁,产生锁互斥count++;}}});thread.start();thread1.start();thread.join();thread1.join();System.out.println(count);}
}
- 首先,什么是锁:
锁本质上是一个OS提供的功能,通过API给到了应用程序,JVM再对这样的API进行包装.这里我们就可以把锁简单地理解为一个不管类型,不管名字,不管是否存在泛型的任意变量,作用上有且只有一个,就是用来区分两个线程知否针对同一个对象加锁. - 如何对线程上锁:
在一个线程中,在某一行使用synchronized ( )关键字,并在括号中传入锁对象,就证明从这一行的{开始,就开始对线程进行了上锁,直到}解锁.
当我们了解完synchronized的第一个特性之后,我们就知道上述上锁的过程是怎么回事了.
1.4.1 synchronized的使用实例
- 修饰代码块
public class SynchronizedDemo {private Object locker = new Object();public void method() {synchronized (locker) {//一系列操作}}
}
- 锁当前对象
public class SynchronizedDemo {public void method() {synchronized (this) {}}
}
- 直接修饰普通方法
public class SynchronizedDemo {public synchronized void methond() {}
}
一旦有线程调用该方法,就会上锁.
- 修饰静态方法
public class SynchronizedDemo {public synchronized static void method() {}
}
1.4.2 synchronized的特性
- 互斥性与锁竞争
在tread线程对count进行++的时候,在count++的外围,我们使用synchronized关键字对count++进行了包裹,由于tread线程启动比tread1早,也就是在此时,线程tread已经拿到了o这把锁.此时由于tread1线程也在RUNNABLE状态,它也想拿到o这把锁.但是发现,o这把锁已经被tread线程占用了,只能阻塞等待,等待tread解锁.tread1进入BLOCKED状态.此时锁就产生了互斥性.
解锁之后,由于系统调度线程的随机性,tread和tread1继续竞争o锁,便会产生锁竞争.
我们举个例子来说明:
有请助教: 小乔,周瑜,兰陵王
由于兰陵王比周瑜先到一步,所以小乔先和兰陵王贴贴 了一段时间.
兰陵王完事之后,兰陵王对小乔解锁,但是兰陵王又觉得自己还没有和小乔贴贴够,但是周瑜又向进去和自己的爱人贴贴,此时兰陵王和周瑜便产生了锁竞争,谁都向对小乔上锁.
如果两个线程对于两个不同的锁进行引用加锁,也就不会出现锁竞争问题:
public class Demo12 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object o1 = new Object();Object o2 = new Object();Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o1) {//o1对线程上锁count++;}}});Thread thread1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o2){//拿到的是o2锁,不会产生锁互斥count++;//线程安全问题仍然存在}}});thread.start();thread1.start();thread.join();thread1.join();System.out.println(count);}
}
但是这样还是会产生线程安全问题.
运行结果:
讨论:join()和上锁的区别
join是在tread全部执行完成之后,再去执行tread1,而加锁是并发执行.
在join等待的时候是WAITING状态,而在上锁过程中是BLOCKED状态.
- 可重入与不可重入(死锁)
我们思考这样几个场景:
- 场景一:一个线程一把锁
public class Demo13 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object o = new Object();Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o) {//对线程上锁synchronized (o){//又上了一次锁count++;}}}});thread.start();thread.join();System.out.println(count);}
}
上面的代码tread两次利用o上锁,我们来思考,在第二次上锁的时候,会不会因为锁的互斥性,而使得tread线程产生阻塞,那就自己把自己锁死了,产生便了死锁.
举例说明:
有请助教:钟离
假如钟离在上厕所…
如果产生上述情况,我们称该锁为不可重入锁.如c++,python中自带的锁,都是不可重入锁,一旦像上面那样写,就锁死了.
但是Java中的锁是可重入锁,对一个线程使用相同的锁进行多次加锁之后,不会出现锁死的情况.不会产生锁冲突.可见Java的创始者为了不让我们Java程序员写出bug,真的是操碎了心!!!
可重入锁的原理:
在可重⼊锁的内部,包含了"线程持有者"和"计数器"两个信息.
• 如果某个线程加锁的时候,先判断这个线程是否被加锁,如果没有,则加锁,如果发现锁已经被⼈占⽤,但是恰好占⽤的正是⾃⼰,那么仍然可以继续获取到锁,并让计数器⾃增.
• 解锁的时候计数器递减为0的时候,才真正释放锁.(才能被别的线程获取到)
举例说明:
有请助教:小乔,周瑜,兰陵王
那么什么时候Java会产生死锁呢?
- 场景二:两个线程两把锁
public class Demo14 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object o = new Object();Object o1 = new Object();Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o) {//1.拿到o锁synchronized (o1){//3.与tread1的o1锁互斥count++;}}}});Thread thread1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o1){//2.拿到o1锁synchronized (o){//4.与tread的o锁互斥count++;}}}//3,4相互等待,最终卡死});thread.start();thread1.start();thread.join();thread1.join();System.out.println(count);}
}
运行结果:毛都没有!
这就说明,这里的的进程卡死,产生了死锁.
什么原理呢?由于tread一首先启动,tread拿到o锁,并上锁,此时tread1启动,拿到o1锁,当tread想要拿到o1锁的时候,发现o1锁被占用,阻塞等待,当tread1想要拿到o锁的时候,发现o锁被占用,阻塞等待,这时候tread1和tread相互循环相互等待,就产生了死锁.
这就像两个相互暗恋的人一样,都彼此暗恋着对方,但是都不敢鼓起勇气去表白,这样就会彼此错过.
- 那么我们如何规避死锁呢?(重点面试题)
首先我们要知道参数死锁的4个必要条件:
- 锁具有互斥性
- 锁不可剥夺
上述是锁的两个基本的特性,我们无法干预 - 请求锁和保持锁
一个线程拿到一把锁之后,不释放这个锁,就尝试获取其他锁. - 循环等待:
多个线程获取多个锁过程中,A等待B,B等待A.
上述两个条件,我们都可以通过干预代码结构来解除死锁.
我们需要约定好加锁顺序,让所有的线程按照一定的顺序加锁.
我们尝试使用上面的方法对上面的场景二的死锁进行解除:调换lock1和lock2的位置,让tread执行完所有的逻辑之后释放锁之后,再轮到tread1执行.
public class Demo14 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object o = new Object();Object o1 = new Object();Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o) {synchronized (o1){count++;}}}});Thread thread1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (o){synchronized (o1){count++;}}}});thread.start();thread1.start();thread.join();thread1.join();System.out.println(count);}
}