【javaEE】多线程——线程安全初阶☆☆☆
文章目录
- 前言
- 观察线程不安全
- 线程不安全的原因
- 此并发程序线程不安全的原因
- 线程不安全的几大条件☆
- 操作系统对于线程的调度是随机的抢占式执行
- 修改同一个变量
- 修改操作不是原子的
- 内存可见性
- 指令重排序
- 解决线程安全
- 怎么解决、解决哪些
- 加锁
- 互斥性
- synchronized的变种写法
- 可重入
- 死锁
- 如何避免死锁
- 死锁的四个必要条件
- 锁是互斥的
- 锁是不可剥夺的
- 请求和保持
- 循环等待
- 死锁小结
- Java 标准库中的线程安全类
- 总结
这里是@那我掉的头发算什么
刷到我,你的博客算是养成了😁😁😁
前言
学习了多线程的基础概念和操作,本章正式进入线程安全问题,线程安全是整个多线程最关键的要点,如果不理解线程安全,很难保证能写出正确的代码。
观察线程不安全
package Thread11_12;public class demo13 {private static int count = 0;public static void main(String[] args) {Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(()->{for (int i = 0; i < 5000; i++) {count++;}});t1.start();t2.start();System.out.println(count);}
}
上面这段代码输出结果是0,原因是线程运行太快了,刚刚启动还没开始运算呢,直接输出了。我们在写代码的时候要注意避免这一点,在此处一个很好的办法就是使用上一章学习的join方法。
package Thread11_12;public class demo12 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(()->{for (int i = 0; i < 5000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
此处的
t1.join();
t2.join();
有以下的运行可能:

所以这两个代码的先后顺序无所谓的。
那么,我们写的这段代码应该会在两个线程分别对count执行5000次自增后停止,所以运行结果应该是10000。
但是!!!!!!!!!
我们的实际结果却是:

并且我们多次运行的结果并不一致:

我们把代码写的更加严谨一些,确保两个线程真的都运行结束了:
package Thread11_12;public class demo12 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {count++;}System.out.println("t1结束");});Thread t2 = new Thread(()->{for (int i = 0; i < 5000; i++) {count++;}System.out.println("t2结束");});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}

可是结果依然不是10000,这是何意味?
因为目前的线程是并发执行的,如果我们调换代码顺序,将这两个线程变为串行执行,就会发现结果居然变得正确了。


很明显,当前的代码就是因为多线程并发而产生的‘bug’,这种现象我们就称为“线程安全问题”,或者“线程不安全”。
反之,如果一段代码在多线程并发的条件下,也不会出现类似于以上的bug,那么这段代码就是线程安全的。
线程不安全的原因
此并发程序线程不安全的原因
我们所写的这段代码中,线程的主要业务就是count++,但是这一段简单的代码在cpu的视角里,其实被分成了三步:
1.load,将count的值从内存中读取到寄存器中
2.add,将指定寄存器中的值进行+1操作再存到寄存器里
3.save,将寄存器中的值重新写回内存
而cpu在执行这三条指令的过程中,很有可能会被打断:
执行123线程切走:
执行12切走…执行3
执行1切走…执行2切走…3
…
线程的指令要在cpu上执行,cpu的内核是有限的,要执行的指令又很多,并且操作系统的调度还是随机的,所以执行任意一个指令时,都有可能触发上述“线程切换”过程。
可能涉及的情况有很多,我们列举一个出问题的情况:

如上图所示,t1先读取count的值为0,此时切换到t2线程,依然读取了0.然后t1自增,t2自增,t2将count=1写回内存,t1随后也将count=1写回内存,最终本应该是2的值变为了1(用t1,t2代指t1,t2线程所用到的寄存器)。
由此来看,出现最终的count值小于10000的情况还是很容易理解的。
思考:count值最小是多少?会比5000小吗?
按照我们上面的推理,如果每一次执行都是类似于我所画的图的情况,每次都少加一次,最极端情况理应为5000。但是,还有一些其他的情况没有被考虑:

诸如以上的情况:
一共进行了三次++操作,但是实际只加了一次。照这么看,如果是进行四次,五次等,最终的结果又会不一样。至于最小会是多少,这是一个数学问题,肯定能算,但是我们能知道,肯定比5000小。
不过,还有一个有趣的现象:
当我们把代码中自增五千次,改成500次时,运行结果会发生改变:
package Thread11_12;public class demo12 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 500; i++) {count++;}System.out.println("t1结束");});Thread t2 = new Thread(()->{for (int i = 0; i < 500; i++) {count++;}System.out.println("t2结束");});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}

这是因为,计算机运行速度太快了,以至于t1线程启动之后在t2线程还没启动之前就运行完了,所以运行结果反而没出错。
线程不安全的几大条件☆
操作系统对于线程的调度是随机的抢占式执行
这是线程不安全的根本原因,如果不是随机调度,都是并发执行,不会有线程安全问题出现。
所以程序员需要保证线程在任何条件下任意执行顺序代码都可以安全的运行。
修改同一个变量
上述代码就是因为同时修改count变量的值导致线程不安全。
当多个线程对变量进行操作时:
一个线程修改一个变量–安全
多个线程并非同时修改一个变量–安全
多个线程修改不同变量–安全
多个线程读取同一个变量–安全
修改操作不是原子的
原子性这个概念我们在事务中讲过,现在我们来重温一下:
原子性的核心是操作不可分割,要么完整执行,要么完全不执行,不存在中间状态。
核心定义
原子性源于 “原子” 不可再分的特性,是计算机科学中保障数据一致性的关键特性,常见于数据库事务和并发编程场景。
关键应用场景
数据库事务:比如转账操作,“扣款” 和 “到账” 必须作为原子操作,要么同时成功,要么同时回滚,避免出现一方扣款另一方未到账的情况。
并发编程:多线程操作共享资源时,原子操作能防止指令被拆分执行,比如用原子类(如 Java 的 AtomicInteger)保证 “i++” 不会因线程切换导致计数错误。
如果一个修改操作对应到cpu里只是一个指令,如java里“=”赋值操作就只有一个指令,那么这个操作是原子的,因为cpu不会出现一条指令执行到一半的情况,指令是cpu执行的最小单位。像我们前面提到的自增操作涉及到三个指令,因此不是原子的。
内存可见性
指令重排序
这两种原因在我们学习了锁之后再讨论
解决线程安全
怎么解决、解决哪些
首先五大条件的第一大条件:操作系统的随机调度,这个就解决不了。想解决的话只能自己写一个新的操作系统😭
第二个条件看似很容易解决,不用就行。但是有时候确实无法避免多个线程修改一个变量的情况,还是解决不了。
别担心,虽然前两个条件解决不了,但好歹剩下三个都有办法
加锁
解决修改操作非原子性的问题是java解决线程安全问题的主要方式。主要思想是对操作进行加锁,打包成一个原子的操作。
而java中提供的api是Synchronized关键字,通过这个关键字写的一段代码满足原子性。
互斥性
一个操作被加了锁之后,其他的操作一旦想加锁,就必须等这个操作把锁释放。两个操作之间是互斥的。
比如我们上述代码可以用锁把count++操作锁起来,这样在自增过程中,就无法被打断。
注意:加锁操作并不是说把这个线程锁死在cpu上,这个线程没法被调度走。而是禁止其他线程加这个锁,想要加这个锁的线程必须阻塞等待。
我们可以试着用Synchronized对之前有问题的代码进行加锁操作,看看线程安全问题是否还存在:
package Thread11_14;public class demo13 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {synchronized (locker){count++;}}System.out.println("t1结束");});Thread t2 = new Thread(()->{for (int i = 0; i < 5000; i++) {synchronized (locker){count++;}}System.out.println("t2结束");});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}

可以看到,结果已经正确了。
Object locker = new Object();Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {synchronized (locker){count++;}}System.out.println("t1结束");});
现在我们来剖析一下加锁的这段代码:
首先synchronized(){}里面的代码是需要被加锁的代码。()里面的参数其实是锁对象,而在java中任何对象都可以当作锁,所以我们可以创建一个Object类(其他类当然也可以)来当作锁。synchronized只包含count++即可,for循环不涉及到线程安全问题
注意:
两个线程加的锁必须是同一个锁。加上了同一个锁时,一个线程得到了锁,另一个线程就得阻塞等待,直到释放锁。
如果是不同的锁,不会有互斥效果,线程安全问题也没法解决。就比如蹲坑时如果等的是一个坑,俩人是互斥的,没法同时进去。如果是不同的两个坑就不必。
思考:如果锁把for循环也加进去会怎样?
如果for循环也在锁里面,其实结果仍然是正确的,因为这意味着for循环的每一次操作也是互斥的。但是,为什么没用这个形式呢?因为for循环并发还是并行没有安全问题,但是并行的效率肯定是比并发高的,加锁之后效率会降低。
synchronized的变种写法
首先我们创建一个count类实现一下正常的写法,运行结果没问题。
package Thread11_14;class Count{private int count = 0;public void add(){count++;}public int get (){return count;}
}
public class demo14 {public static void main(String[] args) throws InterruptedException {Count count = new Count();Object locker = new Object();Thread t1 = new Thread(()->{for (int i = 0; i < 5000; i++) {synchronized (locker){count.add();}}});Thread t2 = new Thread(()->{for (int i = 0; i < 5000; i++) {synchronized (locker){count.add();}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count.get());}}
当然,如果在Count中把目标方法里的count++加上锁效果也是一样的,这个很好理解。但其实synchronized还可以对方法进行直接加锁,这个锁是隐式的,锁对象就是当前类实例对象(即this)。
class Count{private int count = 0;public synchronized void add(){count++;}public int get (){return count;}
}
我们之前讲过StringBuffer是线程安全的,就是因为它对关键操作都加锁了。找到它的源码,我们可以看到很多方法都加了锁:

特殊情况:static表示的方法没法使用this。所以synchronized对静态方法加锁,锁对象就是类对象(比如我们在反射时getClass得到的那个就叫类对象)。
可重入

此处我展示的代码很夸张,实际写代码中很难有人能写出这样的代码,但是如果代码语句很长,也难免会出错,写成类似于这种锁套锁的情况,并且还是同一个锁。
此时,可能会发生“死锁”现象:线程第一次拿到锁很正常,但是第二次想要去拿锁,由于拿锁的条件是释放锁,释放锁的条件是执行完任务,任务又被锁起来了,所以程序会阻塞,无法执行,这种现象就叫死锁。
不过,我截图这个页面似乎没有任何报红啊,是因为java中的synchronized引入了可重入的概念,当一个线程加锁成功之后,后续继续加这个锁,并不会阻塞,而是会继续执行。
死锁
死锁的第一种情况就是上面提到的重入问题,不过这个问题已经被java解决了。
第二种情况:两个线程,两把锁,一个线程拿到一把锁之后,尝试获取对方的锁。
package Thread11_14;public class demo16 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(()->{synchronized (locker1){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1拿到第一个锁");synchronized (locker2){System.out.println("t1拿到第二个锁");}}});Thread t2 = new Thread(()->{synchronized (locker2){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t2拿到第二个锁");synchronized (locker1){System.out.println("t2拿到第一个锁");}}});t1.start();t2.start();}
}


首先这个代码我用了sleep语句保证两个线程都能拿到一把锁。可以看到,两个线程拿到了一把锁之后,因为竞争另一把锁拿不到,进入了阻塞状态(blocked)。
死锁的第三种情况:
哲学家算法:
场景描述:
5 位哲学家围坐在圆桌旁,每两位之间有一根筷子
哲学家交替进行思考和进餐两种状态
进餐必须同时持有左右两根筷子(资源)
进餐完毕后放下筷子继续思考
核心挑战:设计一种机制,确保哲学家们既能正常进餐又不会发生死锁或饥饿。
五个哲学家就相当于五个线程,五个筷子就相当于五把锁,每个哲学家需要拿到左右两把锁才能进食。
如何避免死锁
死锁的四个必要条件
锁是互斥的
一个线程拿到锁之后,另一个线程再尝试获取锁,需要阻塞等待。
锁是不可剥夺的
一个线程拿到锁之后,另一个线程没法抢占这个锁,只能等待
以上两点是锁的基本特性,java的synchronized是严格遵守的。
请求和保持
一个线程在拿到锁1之后,不释放锁的情况下,尝试获取锁2.
只需要规定在获取另一个锁时,需要释放当前的锁就能解决问题。但是可能业务中确实要求持有多个锁才能操作。所以这个方法并不通用。
循环等待
多个线程,多把锁之间的“等待”,陷入了循环。
线程1等线程2 放锁,线程2 等线程3.线程3等线程1 。
破除循环等待方法比较简单,只需要约定好加锁顺序就可以。比如我们写的第二种情况,只需要让两个线程都先获取锁1,再获取锁2 即可。
package Thread11_14;public class demo16 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(()->{synchronized (locker1){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1拿到第一个锁");synchronized (locker2){System.out.println("t1拿到第二个锁");}}});Thread t2 = new Thread(()->{synchronized (locker1){try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t2拿到第二个锁");synchronized (locker2){System.out.println("t2拿到第一个锁");}}});t1.start();t2.start();}
}

再比如哲学家就餐,我们将筷子从0-4进行编码,规定只能先拿小编号的筷子。这样前四个哲学家分别持有一个筷子,而最后一个哲学家拿不到0号筷子,只能阻塞。倒数第二个哲学家就可以拿到第四个筷子,就餐后释放第三个筷子给下一个哲学家,以此类推,哲学家问题就解决了。
死锁小结

Java 标准库中的线程安全类
- 线程不安全的类(集合 + 常用类)
包括ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder,原因是这些类自身没有做任何加锁限制,多线程下操作会有线程安全问题。 - 线程安全的类
分为两类:
基于synchronized加锁的类(不推荐使用):
包括Vector、HashTable、StringBuffer,它们在关键方法上加了synchronized来保证线程安全,但缺点是加锁会带来代价 —— 可能引发锁竞争、线程阻塞,大幅降低程序执行效率。
优化后的线程安全类:
ConcurrentHashMap,是HashTable的高度优化版本(性能更好)。 - 特殊的线程安全类(无锁但安全)
String:虽然没有加锁,但它是不可变类(不涉及 “修改” 操作),因此多线程下操作也不会有线程安全问题。
大家是否还记得为什么String不可修改??

1.String中存储数据的数组被private修饰,无法直接调用修改。
2.String中数组被final修饰,无法指向新的数组,并且内部没有提供修改这个数组的方法
3.内部有一个类似于修改的方法,但返回的是一个新的字符串
- 关键注意点
不是加了synchronized就 100% 线程安全,需结合具体代码逻辑分析;
加锁要谨慎:不需要锁的场景不要盲目加,避免因锁竞争导致的性能损耗。
总结
本文围绕多线程的线程安全与死锁问题展开,核心介绍了多线程并发修改同一非原子操作变量时,因操作系统随机抢占式调度会出现结果异常的线程不安全现象,通过synchronized加锁(支持代码块、方法锁等形式,且具备可重入特性)可保证操作原子性以解决该问题;同时详解了死锁的三类场景(重入、两线程两锁、哲学家问题)及四大必要条件,指出约定统一加锁顺序是打破循环等待、避免死锁的通用方法,并梳理了 Java 标准库中线程不安全类、基于synchronized的线程安全类(如 StringBuffer)、优化类(ConcurrentHashMap)及特殊安全类(不可变的 String),强调加锁需按需使用以避免性能损耗。

