目录
[1.1 死锁的概念](#1.1 死锁的概念)
[1.2 产生死锁的三种情况](#1.2 产生死锁的三种情况)
[1.3 如何避免死锁](#1.3 如何避免死锁)
[1.3.1 构成死锁的四个必要条件](#1.3.1 构成死锁的四个必要条件)
[1.3.2 避免死锁](#1.3.2 避免死锁)
[1.4 死锁小结](#1.4 死锁小结)
[2. Java标准库中的线程安全类](#2. Java标准库中的线程安全类)
[2.1 线程不安全](#2.1 线程不安全)
[2.2 线程安全,采用锁机制](#2.2 线程安全,采用锁机制)
[2.3 线程安全,没有加锁](#2.3 线程安全,没有加锁)
[3.1 内存可见性](#3.1 内存可见性)
回顾上节
1.线程安全问题
1)[根本]随机调度,抢占式执行
2)多个线程同时修改同一个变量
3)修改操作不是原子的
4)内存可见性(本节讨论)
5)指令重排序(本节讨论)
2.解决线程安全问题
1)锁的概念:互斥/排他
2)如何加锁:
synchronized(锁对象){
一些要保证线程安全的代码
}
3)synchronized的变种写法:
在方法内使用synchronized
用synchronized修饰方法
4)可重入
1.关于死锁
1.1 死锁的概念
具体看上篇文章可重入锁部分
1.2 产生死锁的三种情况
情况一
一个线程,一把锁,连续加两次
情况二
两个线程,两把锁,每个线程获得一把锁之后,尝试获取对方的锁
一个死锁的案例:
java
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);
}
synchronized (locker2){
System.out.println("t1线程两个锁都获取到");
}
}
});
Thread t2=new Thread(()->{
synchronized (locker2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1){
System.out.println("t2线程两个锁都获取到");
}
}
});
t1.start();
t2.start();
}
执行代码:
【注意】
必须是,拿到第一把锁,再拿第二把锁(第一把锁不能释放),也就是必须要锁嵌套:
为什么要加sleep?
如果不加入sleep,很可能线程t1 一口气就把两把锁都获取了,此时线程t2还没有开始,自然不会构成死锁
情况三
N个线程,M把锁
一个经典的模型,哲学家就餐问题:
现有五个哲学家 均匀地坐在圆桌周围,圆桌中间有一碗面,并且桌上有五根筷子 ,每个哲学家的左右手都有一根筷子 ,当哲学家同时拿到左右手的两根筷子 时就可以吃面,否则等待
此时的哲学家有两种操作:
1.思考人生(放下筷子,思考)
2.吃面条(拿起左右手的筷子)
并且这 五个哲学家 随机触发 吃面条 和 思考人生这两个操作。
这5个哲学家,就相当于5个线程
5根筷子,就相当于5把锁
每个线程只需要拿到两把锁即可
大部分情况下,上述模型可以很好的运转,但是在一些极端情况下会造成死锁 ,比如:
同一时刻,大家都想吃面条,同时拿起左手的筷子,此时任何一个哲学家都吃不了面
而对于线程来说,这五个线程同时分别获取到 locker1、locker2....locker5,当每个线程获取第二把锁的时候,无论第二把锁的对象是locker1、locker2还是其他的,都会造成阻塞
1.3 如何避免死锁
要避免死锁,首先要知道死锁是如何构成的。
1.3.1 构成死锁的四个必要条件
1.锁是互斥的。一个线程拿到锁之后,另一个线程再尝试获取锁,就必须阻塞等待
2.锁是不可抢占的(不可剥夺)。线程1拿到锁,线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来
3.请求和保持。一个线程拿到锁1之后,不释放锁1的情况下,获取锁2
4.循环等待。多个线程,多把锁之间的等待过程,构成了循环,例如:
A等待B,B等待A 或者 A等待B,B等待C,C等待A
1.3.2 避免死锁
上述四个条件中,条件1和条件2是锁的基本特性,Java中的synchronized是遵循着两点的
破坏掉上述的 条件3 或者 条件4 任何一个条件都能够打破死锁。
对于条件三
如果先放下左手的筷子,再拿起右手的筷子,就不会构成死锁
也就是说,代码中加锁的时候,不要去"嵌套"
但是这种做法是不够通用的
因为事实上,有些情况确实需要拿到多个锁,再进行某个操作(嵌套是很难避免的)
对于条件四约定好加锁的顺序,就可以破除循环等待了
约定:每个线程加锁的时候,永远是先获取需要小的锁,后获取序号大的锁
如果序号小的锁被占用了,就阻塞等待,直到序号小的锁被释放,才拿起第一把锁(序号小的)最后再拿起第二把锁(序号大的)
1.4 死锁小结
1. 构成死锁的场景
a)一个线程一把锁=>可重入锁
b)两个线程两把锁=>代码如何编写
c)N个线程M把锁
2. 死锁的四个必要条件a)互斥
b)不可剥夺
c)请求和等待
d)循环等待
3. 如何避免死锁打破上述 c:把嵌套的锁改成并列
打破上诉 d:加锁的顺序做出约定
2. Java标准库中的线程安全类
2.1 线程不安全
数据结构,集合类:
-ArraysList
-LinkedList
-HashMap
-TreeMap
-HashSet
-TreeSet
-StringBuilder
上面这些集合类自身没有进行任何加锁限制,线程不安全
2.2 线程安全,采用锁机制
但是还是有一些集合类是线程安全的,使用了一些锁机制来控制
-Vector(不推荐使用)
-HashTable(不推荐使用)
-StringBuffer
上面这三个集合类,属于在关键方法加了synchronized
虽然有synchronized,但是不推荐使用
原因:
加锁这个事情,不是没有代价的
一旦代码中使用了锁,意味着代码可能会因为锁竞争,产生阻塞=>程序的执行效率大打折扣
-ConcurrentHashMap相比于HashTable来说,是高度优化的版本(后续详细分析)
2.3 线程安全,没有加锁
还有一些集合类,虽然没有加锁,但是不涉及"修改",仍然是线程安全的
-String
3.再谈线程安全问题
3.1 内存可见性
内存可见性也是造成线程安全问题的原因之一
【案例】
现有两个线程,一个int型成员变量flag,线程t1进行条件为flag==0的while循环,当while循环结束时,线程t1结束。而线程t2则使用scanner.nextInt()对成员变量flag进行修改 :
java
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(()->{
//针对flag进行修改
Scanner scanner=new Scanner(System.in);
System.out.println("请输入flag的值:");
flag=scanner.nextInt();
});
t1.start();
t2.start();
}
按理来说,执行代码后再输入一个值,flag的值就会改变,进而线程t1就会结束。
但是事实并非如此,执行代码:
程序并没有结束,说明t1线程还在继续执行(while循环)
很明显,这也是个Bug,也就是线程安全问题
一个线程读取,另一个线程修改,被修改的值并没有被线程读取到,这种问题被称为:
"内存可见性问题"
而产生这种问题的原因是编译器优化:研究JDK的大佬们,希望通过编译器&JVM对程序员写的代码,自动进行优化。
本来写的代码是进行xxxx,编译器/JVM会在你原有逻辑不变的前提下,对你的代码进行调整
使程序效率更高
编译器,虽然声称优化操作是能够保证逻辑不变。
但是事实上,尤其是在多线程的程序中,编译器的判断可能会出现失误
这就可能导致编译器的优化,使优化后的逻辑,和优化前的逻辑 出现细节上的偏差
对于上述案例代码,分析出现问题的原因:
对于while里的条件判断操作,可以看成是一个cmp这样的指令(条件跳转)
而在判断之前,首先要进行load操作(读取内存中flag的值,然后再进行判断)
对于这个while循环,短时间内会循环很多次,所以,while(flag==0){ }这个代码可以等效于:
while{
load
cmp
}
而对于load和cmp:
load:是读内存操作
cmp:是纯cpu寄存器操作
因此,load的时间开销可能是cmp的几千倍
对于上述循环而言,flag的改变取决于用户的输入(System.in),而这段时间对于计算机来说是很长的(在用户输入值的这段时间内,while循环已经执行了很多很多次了)
在这个执行过程中,JVM就能感知到,load反复执行的结果,好像都是一样的
JVM认为:既然结果都是一样的,为何还要反复执行折这么多次
于是,JVM就把读取内存的操作,优化成读取寄存器这样的操作
(把内存的值读取到寄存器,后续再load,不需要再读取内存,直接从寄存器里取)
于是,等到用户再输入值,修改flag时
此时的t1线程早已经感知不到了(编译器优化,使得t1线程的读操作,不是真正读内存)
如果稍微调整上述代码,给while循环内加入sleep(1),就不会出现这样的问题了:
执行结果:
原理:
因此,JVM就不再对此部分进行优化了
但是,针对内存可见性问题,也不能指望通过sleep来解决
使用sleep会大大影响到程序运行的效率
如何不使用sleep也能解决上述的内存可见性问题呢?
volatile关键字
Java语法中,有一个volatile关键字:
通过这个关键字来修饰某个变量,此时编译器对这个变量的读取操作,就不会被优化成读寄存器
再次执行程序,没有出现内存可见性问题:
这样的变量读取操作,就不会被编译器进行优化了。
既然谈到了volatile,就不得不谈谈JMM(Java Memory Model,Java 内存模型)
JMM
Java内存模型,Java官方文档的术语:
每个线程,有一个自己的**"工作内存"(work memory),同时这些线程共享同一个"主内存"(main memory)。**当一个线程循环进行上述读取变量操作的时候,就会把主内存中的数据,拷贝到该线程的工作内存中。后续另一个线程修改,也是先修改自己的工作内存,拷贝到主内存里。由于第一个线程仍然在读自己的工作内存,因此感知不到主内存的变化
咱们前面讲的是,把读内存的操作,优化成读寄存器操作,其实是同样的意思。
【注意】
work memory:这里说的工作内存,其实并不是我们常说的"内存",就是指cpu的寄存器
main memory:这才是我们真正所说的内存
完
如果哪里有疑问的话欢迎来评论区指出和讨论,如果觉得文章有价值的话就请给我点个关注还有免费的收藏和赞吧,谢谢大家