文章目录
- 观察线程安全问题
- 线程安全的概念
- 出现线程安全问题的原因
- 共享数据
- 原子性
- 总结
- synchronized - 锁
- synchronized 特性
- 互斥
- 可重入
- synchronized 的使用
- 修饰普通方法
- 修饰静态方法
- 修饰代码块
- 解决线程安全问题
- 两个线程两把锁
- 哲学家就餐问题 - N个线程M把锁
- 解决策略
- 死锁成因总结
观察线程安全问题
有些代码在单线程环境下执行,完全正确,但是如果同样的代码,在多线程环境下执行,就可能出现Bug,这种问题称为“线程安全问题” 或是"线程不安全"。我们先看一个例子。
public class Demo01_CountIncrease {private static int count = 0;private static Object locker = new Object();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++) {synchronized (locker) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
运行结果~~
67779
按照我们对这个多线程程序的预期,一个线程对count变量自增5w次,结果应该是10w,但是这里的结果不是,这里明显是bug,这个bug是由多线程引起的,这就是线程安全问题。那么为什么会产生这样的问题呢?这就要对线程安全有一定的了解.
线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:如果单线程环境下代码运行的结果是符合我们预期的,多线程环境结果也符合预期,则说这个程序是线程安全的。反之则是线程不安全的,即出现了线程安全问题。
出现线程安全问题的原因
综上,我们意识到了,多线程程序要保证无论操作系统调度顺序如何,我们都要保证写出的程序能够正常执行.如果只是如上图这样调度的话并不会出现线程安全问题
共享数据
上面的代码中count,是一个静态成员变量,由整个类共享。此时多个线程都可以访问到这个内存。从而对这个内存进行一些非原子操作,导致了线程安全问题。如果t1,t2线程操作的是两个变量就不会产生这个问题,因为无论怎么调度,自增的结果是独立保存的.
原子性
指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。原子性的操作是一个不可分割的操作。一条java 语句不一定是原子的,也不一定只是一条指令,上面代码中的count++其实是由三条指令共同完成。
- load 将count的值从内存中读到寄存器中
- add 将寄存器中count的值进行+1
- save 将寄存器的值写回内存
从上图的解释中,我们可以清晰的看到,两个线程的两次自增只生效了一次,如果t1线程load后t2线程对count自增多次,那么t2线程做的都是无用功,当t1线程再次被调度执行时,回覆盖掉原有的内存.这是产生线程安全的重要原因,++操作不是原子的.
非原子性是产生线程安全的一个重要原因,如果一个线程正在对一个变量操作,中途其他线程穿插执行,这个操作被打断了,结果就可能是错误的。
总结
总结一下产生线程安全的原因
- 操作系统中,线程调度执行的顺序是随机的,抢占式执行。
- 两个线程针对同一个变量进行修改,即修改共享数据。
- 一个线程,针对同一个变量进行修改 。 线程安全
- 两个线程针对两个不同的变量修改。 线程安全
- 像个线程针对一个变量进行读取。 线程安全
- 修改操作不是原子的。
- 内存可见性问题
- 指令重排序问题
synchronized - 锁
锁是为了解决线程安全问题引入的,对代码进行枷锁操作,可以让这部分代码不会被其他线程穿插执行(变为并行),这是锁的互斥特性,如果一个线程已经持有锁,再进行枷锁操作,一般来说就会产生死锁。
synchronized 特性
互斥
synchronized 会起到互斥效果, 如果t1线程已经对locker对象加锁成功,那么t2线程在想对locker对象进行加锁是,就会进入阻塞,这种情况就是产生所竞争了
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
可重入
Java中的synchronized 是一把可重入锁,一个线程可以对一个锁对象进行多次枷锁,而不会出现死锁的情况。而C++的std::mutex则是一把不可冲入锁,如果针对一个线程针对一个锁对象进行多次枷锁操作就会产生死锁的情况。
public class Test5 {private static Object locker = new Object();public static void main(String[] args) {Thread t = new Thread(() -> {synchronized (locker) { // ①synchronized (locker) { // ②System.out.println("可重入锁验证");}// ③}// ④});t.start();}
}
执行结果~~
在上述代码中t线程针对locker加了两次锁,我们来分析一下。我们以不可重入锁分析当前代码,当代码执行到①时,此时第一次获取锁,继续执行到②时,此时再次获取锁,而由于锁已经被t1获取了,还没有释放,所以这里拿不到锁就不能继续往下执行了,就会产生死锁的情况。
但是Java中的synchronized是一把可重入锁,可以针对一个对象多次枷锁,对象头中有一个计数器进行记录当前线程针对锁枷锁的次数,当线程一次次释放锁将计数器归零时,才会真正的释放锁。
synchronized 的使用
对象在构造时,不仅构造了成员属性的空间,还开辟了一些其他的空间。比如对象头Class Header,对象头中包括mark word和class pointer,其中mark word就是记录锁的,而class pointer则是指向该对象所属的类。所以锁是存放在对象头中,枷锁是针对对象枷锁,所以在枷锁之前得有一个锁对象。
修饰普通方法
// ①synchronized public void increase1() {count++;}// ②public void increase2() {synchronized (this) {count++;}}
这个代码中的①②是等价的。
修饰静态方法
// ③
synchronized public static void increase3() {}
// ④
public static void increase4() {synchronized (Counter.class) {}
}
这个代码中的③④是等价的。
修饰代码块
public class Test6 {private static Object locker = new Object();public void test() {synchronized(locker) {}}
}
解决线程安全问题
两个线程两把锁
public class Demo16 {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker2) {try {// 此处的sleep很重要,要确保 t1 和t2 分别拿到一把锁后在获取第二把锁Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker1) {System.out.println("t1 枷锁成功");}}});Thread t2 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t1 枷锁成功");}}});t1.start();t2.start();}
}
执行结果~~
这个代码中出现了两个线程两把锁,但是由于上锁的时机不对,形成了环就造成了死锁的情况,好比车钥匙在家里,家里的钥匙在车里~~
哲学家就餐问题 - N个线程M把锁
我们强化一下上面的命题,我们发现当出现N个线程M把锁,也可能会产生死锁。
哲学家就餐问题:
- 五位哲学家围坐在一张圆形餐桌就餐(吃意大利面)
- 他们可以做以下两件事情之一:吃面或思考。
- 吃东西的时候,他们就停止思考,思考的时候也停止吃东西。
- 餐桌中间有一大碗意大利面,每两个哲学家之间有一只筷子。
- 因为用一只筷子很难吃到意大利面,所以假设哲学家必须用一双筷子吃面。
- 他们只能使用自己左右手边的那两只筷子。
如果每个哲学家都拿起自己左手边的筷子,这时五个哲学家都只有一只筷子,从而大家都不能吃到面,这就产生了死锁。在这个过程中,哲学家是线程,意大利面好比“共享资源”,而想要拿到共享资源就得同时获取到两把锁。
那么这个问题怎么解决呢?我们给每根筷子编号,每个哲学家只能拿到自己左右手边较小编号的筷子。
这样哲学家A拿到1号筷子,哲学家B拿到2号筷子,哲学家C那都3号筷子,哲学家D拿到4号筷子。当哲学家E想要拿1号筷子时,1号筷子已经被哲学家A拿到了,所以哲学家E就在这等着。此时哲学家D看见5号筷子空闲出来就拿起5号筷子开始炫面,哲学家D吃完放下两只筷子,哲学家C就可以开始吃面了,后面同理。这样所有的哲学家都能够吃到面。
解决策略
我们可以向解决哲学家问题那样,给锁编号约定从小的锁开始使用,这样就可以避免锁成环,从而避免了死锁问题。
在某些特定场景下可以通过调整代码结构,来规避线程安全问题。而解决线程安全的主要手段主要是对代码进行枷锁操作。
我们这里约定t1 t2线程都从编号小的锁开始使用,这样就可以解决这个问题了。
public class Demo16 {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {try {// 此处的sleep很重要,要确保 t1 和t2 分别拿到一把锁后在获取第二把锁Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker1) {System.out.println("t1 枷锁成功");}}});Thread t2 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t2 枷锁成功");}}});t1.start();t2.start();}
}
执行结果~~
t1 枷锁成功
t2 枷锁成功进程已结束,退出代码为 0
;我们约定好枷锁顺序后,t1获取到锁之后t2就要阻塞等待等到t1释放完锁1之后在进行获取锁,这样就不会形成环,从而解决了线程安全问题。
注:
- 枷锁对象不重要,重要的是通过这个对象来区分两个对象是否在竞争同一个锁
- 如果两个线程针对同一个对象枷锁,就会产生锁竞争,如果不是针对同一个对象枷锁,就不会有所竞争,仍然是并发执行
死锁成因总结
死锁要形成要满足四个冲要条件:
- 互斥使用(锁的基本特性): 当一个线程持有一把锁后,另一个线程也想获取当前锁,就会进入阻塞
- 不可抢占(锁的基本特性):当锁以及被一个线程获取后,另一个线程只能等之前加锁的线程解锁后,才能获取锁,不能强行抢占
- 请求保持(代码结构):一个线程尝试获取多把锁是,之前获取的锁并不会释放
- 循环等待(代码结构):等待的以来关系形成环
综上,我们只需在加锁时进行约定,按照一定的顺序进行加锁,避免加锁的依赖形成环,就可以破解死锁了.