Java多线程2--Java的线程安全问题
Java多线程超级详细解析,深入细节,真的是掰开揉碎的去讲,Java线程安全问题以及wait,notify等
Java多线程
一级目录
二级目录
三级目录
Java多线程
1. 线程的状态
1.1 线程状态的分类
- NEW: 安排了工作(线程刚刚创建), 还未开始行动
- RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.(即线程在cpu上执行或者线程随时可去cpu上面执行)
- BLOCKED: 这几个都表示排队等着其他事情(线程在等待获取synchronized锁,被阻塞在同步块 / 方法之外。特点:此时线程 “排队”,只有拿到锁才能继续执行。当获得锁 → RUNNABLE)
- WAITING: 这几个都表示排队等着其他事情(线程无限期等待,直到被其他线程显式唤醒(notify()/notifyAll())或被中断)
- TIMED_WAITING: 这几个都表示排队等着其他事情(线程在指定时间内等待,超时后会自动唤醒,也可被其他线程唤醒。)
- TERMINATED: 工作完成了.
注意:几个可能混淆的知识点
-
BLOCKED <–> WAITING/TIMED_WAITING:
BLOCKED:等待锁(被动排队)
WAITING/TIMED_WAITING:主动调用方法进入等待,需要被唤醒或超时 -
WAITING <–> TIMED_WAITING:
WAITING:无限期等待
TIMED_WAITING:有超时时间,到点自动醒 -
WAITING 和 TIMED_WAITING 的唤醒条件
WAITING的触发场景:
obj.wait()(无超时)
thread.join()(无超时)
LockSupport.park()TIMED_WAITING的触发场景:
Thread.sleep(ms)
obj.wait(ms)
thread.join(ms)
LockSupport.parkNanos()/parkUntil()
1.2 查看线程状态的方法
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
for (int i = 0; i < 5; i++) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
System.out.println(t.getState());
t.start();
System.out.println(t.getState());
t.join();
System.out.println(t.getState());
}
运行结果
1.3 线程各个状态之间的转换

2 多线程带来的问题–线程安全问题
2.1 线程安全问题的产生
我们先看一段代码
public static int count = 0;
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,t2之后输出结果
t1.join();
t2.join();
System.out.println(count);
}
注:这里对于t1.join()和t2.join()的理解,作一下说明:
情况1:t1先结束,t2后结束
main线程在t1.join()阻塞等待t1结束,main线程再在t2.join()阻塞等待t2结束,main继续执行后续打印,最后打印的结果的值就是t1,t2都执行完的值。
情况2:t2先结束,t1后结束
main先在t1.join()阻塞,main先在t1.join()处阻塞,t2结束,t1.join()继续阻塞,t1结束,t1.join()继续执行,main执行到t2.join(), 由于t2已经结束了,此处的t2.join()是不会阻塞的!!!,main继续执行后续打印,最终的结果,打印的值,还是t1, t2都执行完的值
我猜大家预想的结果都会是100000吧,可是结果并非如此:
我们运行结果5次,发现这些都是小于100000的随机值
这样的代码是明显有bug的,这样由于多线程并发执行导致的问题叫做线程安全问题
我们在cpu的角度来看待这个问题,count++这条语句看起来就是一行代码,实际上对应到3个cpu指令
- load 把内存中的值(count变量)读取到cpu寄存器
- add 把指定寄存器中的值,进行+1操作,执行结果还是放到这个寄存器中
- save 把寄存器中的值写回到内存当中
这样的代码在我们以往单线程条件下完全没有问题,可是在多线程条件下,由于线程并发执行,线程可能随时调度切换,这种调度是随机的,就可能引发问题,下面看一下这里面的细节:



通过上述过程演示:我们发现如果如果从内存load到两个寄存器的值都为0时,则会出现少加的情况,因此,这个程序的执行结果一定比100000要小。
有人可能还会有疑问:这个代码的运行结果是不是一定大于等于50000?
答案是不一定
如上图所示:一共进行三次count++,可是最终的结果还是1,所以代码的运行结果可能小于50000
2.2 线程不安全的原因
- 操作系统对于线程的调度是随机的,抢占式执行
- 多个线程同时修改同一个变量
如果是一个线程修改一个变量–没有问题
如果是多个线程,不是同时修改同一个变量–没有问题
如果是多个线程修改不同变量–没有问题
如果是多个线程读取同一个变量–没有问题
有问题的是多个线程修改同一个变量 - 修改操作,不是原子的
- 内存可见性问题,引起线程的不安全(后续在讨论)
- 指令重排序,引起的线程不安全(后续在讨论)
2.3 如何解决线程安全问题
2.3.1 操作系统对于线程的调度是随机的,抢占式执行
这是操作系统底层设定,提高系统的执行效率,我们无法干预
2.3.2 多个线程同时修改同一个变量
这和代码结构直接相关,虽然我们可以调整代码结构,规避一些线程不安全的代码,但这样不够通用,有些情况下需求上就是需要多线程修改同一个变量。这样无法从根源解决问题
2.3.3 修改操作不是原子的
Java中解决线程安全问题最主要的方案–加锁
通过加锁,让不是原子的操作,打包成一个原子的操作
这样,我们可以使用锁,把刚才不是原子的count++包裹起来,在count++之前,先加锁,然后执行count++操作,再解锁。
注意: 加锁操作,不是把线程锁死到cpu上,禁止这个线程被调度走,而是禁止其他线程重新加这个锁,避免其他线程的操作,在当前线程执行过程中插队。打个比方,就像上厕所,需要一个人先进入(获得cpu), 然后又上锁(加锁),才能执行上厕所而不被别人打扰的操作(runnable)

2.4 synchronized (同步的)关键字
2.4.1 synchronized的特性1–互斥
2.4.1.1 synchronized
Java中使用synchronized这样的关键字,搭配代码块,来实现类似的效果
这个()中的锁对象的类型不重要,重要的是多个线程必须对这同一个锁对象加锁。
我们对刚才出错的代码加上synchronized代码块,可以看到问题得到解决
public 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 < 50000; i++) {
synchronized(locker){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized(locker){
count++;
}
}
});
t1.start();
t2.start();
//目的是:保证主线程在线程t1,t2之后输出结果
t1.join();
t2.join();
System.out.println(count);
}
运行结果为:
以下的代码和之前的代码有什么区别吗?
这段代码是对整个for循环加锁,意味着整个for循环,i<50000, i++,count++都是以互斥的方式执行。
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
synchronized(locker){
for (int i = 0; i < 50000; i++) {
count++;
}
}
});
Thread t2 = new Thread(()->{
synchronized(locker){
for (int i = 0; i < 50000; i++) {
count++;
}
}
});
t1.start();
t2.start();
//目的是:保证主线程在线程t1,t2之后输出结果
t1.join();
t2.join();
System.out.println(count);
}
以下是这两种加锁方式的代码执行流程的对比
注意:上图左边的部分,t1,t2包含两个i, 尽管没有对这部分代码加锁,这两个i并不会相互影响,因为他们之间的变量作用域不同,都是在for循环之内,互不影响。
2.4.1.2 synchronized的变种写法
2.4.1.2.1 直接就是普通方法
public class SynchronizedDemo {
public synchronized void methond() {
}
}
本质:使用synchronized修饰方法,就相当于针对this进行加锁
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
2.4.1.2.2 修饰静态方法: 锁的 SynchronizedDemo 类的对象
由于静态方法没有this指针,所以 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
本质:
public static void method(){
synchronized(SynchronizedDemo.class){
}
}
2.4.2 可重入
2.4.2 可重入机制的解析
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
1 for (int i = 0; i < 50000; i++) {
2 synchronized (locker) {
3 synchronized (locker) {
4 count++;
5 }
}
}
正常情况下,当执行到第二行,申请加锁,然后加锁成功,当代码运行到第三行的时候,该线程继续申请加锁,按理来说,这就会因为2行没有释放锁而导致第3行阻塞,最终导致死锁。
Java引入可重入机制之后,因为这种情况而导致死锁的问题得到了解决,当某一个线程针对一个锁加锁成功之后,后续该线程再次针对这同一个锁进行加锁,不会触发阻塞,而是直接往下走。但是,如果是其他线程尝试加锁,就会正常阻塞。
public 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 < 50000; i++) {
synchronized(locker){
synchronized(locker){
synchronized(locker){
count++;
}
}
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized(locker){
count++;
}
}
});
t1.start();
t2.start();
//目的是:保证主线程在线程t1,t2之后输出结果
t1.join();
t2.join();
System.out.println(count);
}
可以看到,由于Java的可重入机制,可以看到程序可以正确运行
2.4.2 可重入机制的实现
实现的原理可以理解为,先引入一个变量计数器,它的初始值为0,每次触发{的时候,把计数器++,每次触发}的时候,把计数器–, 当计数器–为0的时候,此时执行解锁操作。
注:最外层{是真正加锁,最外层}是真正解锁
常见面试题:
3. 死锁
3.1 死锁情形1:
一个线程,一把锁,连续加锁2次(刚才我们提到过,Java通过可重入机制,解决了这种情况下的死锁)
3.2 死锁情形2:
两个线程,两把锁,每个线程成功获取一把锁之后,都在持有原来的锁之后,还尝试获取对方的锁
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("t1线程两个锁都获取到");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2){
System.out.println("t2线程两个锁都获取到");
}
}
});
t1.start();
t2.start();
}
我们可以利用调试工具看到t1,t2两个线程都被阻塞了


注意:若我们把上述代码去掉sleep会出现什么情况呢?
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker2){
synchronized (locker1){
System.out.println("t1线程两个锁都获取到");
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
synchronized (locker2){
System.out.println("t2线程两个锁都获取到");
}
}
});
t1.start();
t2.start();
}
运行结果:
如果不加sleep
很有可能t1一口气就把locker1和locker2都拿到了,这个时候t2,还没开始开动呢,自然无法构成死锁,因此sleep的目的:让线程t1拿到locker2之后阻塞1s,确保让线程t2拿到locker1。
3.3 死锁情形3–哲学家就餐问题
哲学家就餐问题

大部分情况下,上述模型,可以很好的运转,在一些极端情况下会造成死锁。同一时刻,大家都想吃面条,同时拿起左手的筷子。此时任何一个哲学家(线程)都无法拿起右手的筷子,因此任何哲学家都吃不了面条,而哲学家是很执拗的人,已经占有的筷子不会放下,而是在一直申请资源,这样却得不到资源,因此产生了死锁。
3.4 死锁产生条件总结:

构成死锁的四个必要条件(重要)
四个条件都是必要条件,因此只要破坏一个,即可解除死锁
- 锁是互斥的: 一个线程拿到锁之后,另一个线程再尝试获取锁,必须要阻塞等待(可以理解为信号量,目前信号量为0的会阻塞)
- 锁是不可剥夺的:线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来
注释:前两个产生死锁的条件是锁的基本性质,无法破坏这两点,进而达到解除死锁的目的
- 请求和保持:一个线程拿到锁1之后,不释放锁1的前提下,获取锁2。
解决方法:如果先放下左手的筷子(锁),再拿右手的筷子,就不会构成死锁,但是这种方法不够通用
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
synchronized (locker2){
}
synchronized (locker1){
System.out.println("t1线程两个锁都获取到");
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
}
synchronized (locker2){
System.out.println("t2线程两个锁都获取到");
}
});
t1.start();
t2.start();
}
这样,把嵌套的锁改为并列的锁,就可以让线程先放弃一个锁,在申请另一个锁。
- 循环等待:多个线程,多把锁之间的等待过程,构成了循环。


4.Java 标准库中的线程安全类
4.1 线程不安全的数据结构
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
4.2 线程不安全的数据结构
但是还有一些是线程安全的. 使用了一些锁机制来控制.
• Vector (不推荐使用)
• HashTable (不推荐使用)
• ConcurrentHashMap
• StringBuffer

注意:
- 一定要考虑清除,这个地方是否确实需要加锁,不需要的时候不要乱加
- 也不是写了synchronized就100%线程安全,得具体代码具体分析
5. 内存可见性
5.1 内存可见性问题
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的值");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
我们打开调试工具

可以看到我们输入的flag值并不能影响线程t1的运行,很明显,这也是个bug,线程安全性问题,一个线程读取,一个线程修改,修改线程修改的值,并没有被读线程读到,这就是“内存可见性问题”!
5.2 内存可见性问题原理解析

注意:
-
通过了编译器优化,使得t1线程的读操作,变成不是真正读取内存。
-
由于计算机计算的速度很快,从运行程序到用户输入所需的几秒时间,在cpu看来可以执行几千几万次计算任务,所以JVM会认为,没有新的输入,然后就会采取编译器优化,进而直接读取寄存器中存入的数据。
形象的比喻

5.3 内存可见性问题的解决方案
5.3.1 解决方案一(非最优)
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag==0){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag的值");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
通过加入sleep语句,延长单词循环的时间,可以看到,该内存可见性问题得到了解决
然而,sleep语句大大降低了程序运行的效率,需要寻求更完美的解决方案
5.3.2 解决方案二
Java在语法中引入了volatile关键字(adj. 易变的),通过这个关键字来修饰某个变量,此时编译器对这个变量的读取操作,就不会被优化成读取寄存器,而是保持原有的直接读取内存。
public volatile 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的值");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
我们可以看到,通过在flag变量前面添加volatile这个变量这一个简单的操作,内存可见性问题就会被解决(volatile关键字会阻止编译器优化)
5.3.3 volatile和synchronized关键字的区别
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性
6. Java内存模型(JMM)
6.1 设置JMM的目的
JMM 就是为了规范线程和内存之间的交互规则,解决这些问题,让开发者不用关心底层 CPU / 操作系统的差异,写出跨平台的并发代码。
6.2 Java官方文档的关于JMM的描述
每个线程,都有一个自己的**“工作内存”,同时这些线程共享一个“主内存”,当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中;后续另一个线程修改**,也是先修改自己的工作内存,拷贝到主内存里面。
注:由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化
| 内存类型 | 归属 | 特点 |
|---|---|---|
| 主内存(Main Memory) | 所有线程共享 | 存储所有共享变量(如静态变量、堆对象) |
| 工作内存(Working Memory) | 每个线程独有 | 主内存变量的“副本”,线程操作变量时,先拷贝到工作内存,修改后再写回主内存 |
7. wait & notify
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.
wait和notify语句可以让后执行的逻辑,等待先执行的逻辑,先跑。
虽然无法直接干预调度器的调度顺序,但是口语让后执行的逻辑(线程)等待,等待到先执行的逻辑跑完了,通知以下当前的线程,让他继续执行
注意:wait & notify 与 join 的区别
- join是等另一个线程彻底执行完,才继续走。
- wait也是等,等到另一个线程执行notify,才继续走(不需要另一个线程执行完)
7.1 wait()方法
wait 做的事情:
• 使当前执行代码的线程进行等待. (把线程放到等待队列中)
• 释放当前的锁
• 满足一定条件时被唤醒, 重新尝试获取这个锁.
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件:
• 其他线程调用该对象的 notify 方法.
• wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
• 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
System.out.println("wait之前");
synchronized (object){
object.wait();
}
System.out.println("wait之后");
}
注:必须要对wait进行加锁操作


7.2 notify()方法
notify 方法是唤醒等待的线程.
• 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
• 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)
• 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
try {
Thread.sleep(10000);
System.out.println("wait之前");
synchronized (locker){
locker.wait();
}
System.out.println("wait之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入任意内容,通知唤醒t1");
scanner.next();
synchronized (locker){
locker.notify();
}
});
t1.start();
t2.start();
}
在wait之前输入数据,发现t2中的notify无法唤醒阻塞的t1线程
如果我们等到t1执行完语句"wait之前",也就是t1正在wait状态,此时我们再进行输入,此时线程t1就可以被唤醒
注意
scanner.next() 此处的next就是一个带有阻塞操作等待用户在控制台输入
这里同样也是,需要先拿到锁,再进行notify(属于是Java中给出的限制)
java synchronized (locker){ locker.notify(); }wait操作必须要搭配锁来进行,wait会先释放锁。notify操作,原则上说,不涉及到加锁解锁操作,在java中,也强制要求notify搭配synchronized使用
wait和notify是针对同一个锁对象,才能生效的,这个相同的对象,是这两个线程沟通的桥梁。
一定要确保notify的执行要在wait之后(只有先等待,才能被唤醒)
7.3 notifyAll()
如果有多个线程在同一个对象上wait,进行notify的时候是随机唤醒其中一个线程,一个notify唤醒一个wait
而notifyAll()一次唤醒所有的wait线程
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
try {
System.out.println("t1wait之前");
synchronized (locker){
locker.wait();
}
System.out.println("t1wait之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread t2 = new Thread(()->{
try {
System.out.println("t2wait之前");
synchronized (locker){
locker.wait();
}
System.out.println("t2wait之后");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
//一次notify唤醒一个wait
Thread t3 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("输入任意内容,唤醒线程");
scanner.next();
synchronized (locker){
locker.notifyAll();
}
// System.out.println("输入任意内容,唤醒线程2");
// scanner.next();
// synchronized (locker){
// locker.notify();
// }
});
t1.start();
t2.start();
t3.start();
}
运行结果为:
虽然同时唤醒了t1和t2,由于wait唤醒之后需要重新加锁,其中某个线程,先加上锁,开始执行,另一个线程因为加锁失败,再次阻塞等待,等到先走的线程解锁了,后走的线程才能加上锁,继续执行。
7.4 wait 和 sleep 的对比(面试题)
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间.
当然为了面试的目的,我们还是总结下:
- wait 需要搭配 synchronized 使用. sleep 不需要.
- wait 是 Object 的方法 sleep 是 Thread 的静态方法.
更多推荐




所有评论(0)