在上一篇中我们引出了线程安全的概念,本篇文章中我们主要来探讨线程安全出现的原因,解决方
案,以及有线程安全引出来的死锁
线程分析
以代码为例
java
private 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.join();
t2.join();
System.out.println(count);
}
对于这样一段代码,我们发现即使加入join等待线程的命令还是无法得到想要的结果,那我们不妨
从cpu执行指令的角度来分析这段代码
CPU角度
CPU内部包含寄存器这样的模块,寄存器也能存一些数据
对于count++这个操作是一行代码,但实际上对应3个CPU指令
- load,把内存中的值(count变量)读取到cpu寄存器上
- add,把指定寄存器中的值进行+1操作(结果还是在这个寄存器中)
- save,把寄存器中的值写回到内存中
CPU在执行这三条指令是,随时可能会出发线程的调度切换的
例如:
- 123线程切走
- 12线程切走,线程切回来23
- 1线程切走,线程切回来2,线程切回走,线程切回来3
由于操作系统的调度是"随机的",执行任何一个指令的过程中,都可能会触发上述"线程切换"操作
线程的调度是随机的不可预期的
所以随机调度,抢占式执行是导致线程安全问题的主要原因
结果分析
两个线程在CPU上执行的时候可能是并发执行(在同一个cpu上执行),可能是并行(在不同cpu上执
行),两个线程有不同的上下文(一组自己的寄存器的值)
对于count这个结果,我们可以通过花时间轴为例

当t1从内存上读取到count的值之后,cpu突然被调度去执行t2,完成t2一系列的操作后,保存到内
存上的count值此时为1,此时t1上的count值仍为0,经过一系列操作之后,t1保存到内存上的值仍
为1,理论上应该为2
其他情况



还会有其他多种情况,但是通过以上几个例子,结果正确只有一个线程的load得在另一个线程的
save之后
线程调度是时间限制的,那么我们可不可以在t2线程调度之前把t1线程执行完毕
java
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for (int i = 0; i < 50; i++){
count++;
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 50; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}

如果次数表少,结果是正确,这种执行方式类似于串行执行
线程安全产生的原因
- 1.[根本原因]操作系统对于线程的调度是随机的,抢占示执行
- 2.多个线程同时修改同一个变量

这里面我们都是对count这一个变量进行修改
但有几种情况是不会出现结果相互覆盖的情况
一个线程修改一个变量
多个线程,不是同时修改同一个变量
多个线程修改不同变量
多个线程读取同一个变量
修改操作->写
取值操作->读
- 3.修改操作不是原子的
如果修改操作只是对应到一个cpu指令,就可以认为是原子的,cpu不会出现"一条指令执行到一半
的情况"
如果对应到多个cpu指令,就不是原子的
- 4.内存可见性问题
- 5.指令重排序
解决线程不安全问题
- 1.[根本]操作系统对于线程的调度是随机的,抢占式执行
底层设计,改变不了
- 2.多个线程同时修改一个变量
和代码结构相关,但大多数情况下不够通用
- 3.修改操作,不是原子的
这是解决Java线程安全的最主要方案
通过加锁的操作,让不是原子的操作打包成一个原子的操作
加锁和解锁
加锁操作不是把线程锁死到cpu上,而是禁止这个线程被调度走,但是禁止其他线程重新加这个
锁,避免其他线程的操作在当前线程执行过程中插队
加锁/解锁本身是操作系统提供的api,java中使用synchronized这样的关键字搭配代码块来实现类
似的效果
synchronized

对于这个代码块,()括号里填写的是用来加锁的对象,在Java中,任何一个对象都可以用作"锁"
这个对象的类型是啥不重要,重要的是是否有多个线程针对这同一个对象加锁
加锁
java
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Object ob=new Object();
Thread t1=new Thread(()->{
for (int i = 0; i < 50000; i++){
synchronized (ob) {
count++;
}
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (ob) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}

两个线程针对同一个线程才会产生相互排斥效果
如果是不同的锁对象是不会产生互斥效果
java
public static void main(String[] args) throws InterruptedException {
Object ob=new Object();
Object ob2=new Object();
Thread t1=new Thread(()->{
for (int i = 0; i < 50000; i++){
synchronized (ob) {
count++;
}
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (ob2) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}

synchronized的变种写法
可以使用synchronized修饰方法

对于这个方法可以变为更方便的形式

使用synchronized修饰方法就相当于是针对this进行加锁
方法中还有一种特殊情况,static修饰的方法不存在this,此时synchronized修饰的是static方法,相
当于针对类对类象加锁
(类对象在反射里提到过,反射api拿到的信息,都是从类对象中拿到的)

StringBuffer,Vector这些对象方法上就是带有synchronized(针对this加锁)
synchronized修饰普通方法,相当于给this加锁
synchronized修饰静态方法,相当于给类对象加锁
监视锁
JVM中采用的一个术语,使用锁的过程中抛出一些异常,可能会看到监视器锁这样的报错信息
死锁
java
class Counter2{
private int count=0;
public int get(){
return count;
}
public void add(){
synchronized(this) {
count++;
}
}
}
public class Demo06 {
public static void main(String[] args) throws InterruptedException {
Counter2 counter2=new Counter2();
Thread t=new Thread(()->{
for (int i = 0; i < 500000; i++) {
synchronized (counter2){
counter2.add();
}
}
});
t.start();
t.join();
System.out.println(counter2.get());
}
}
上述代码中存在一个问题


- 1.第一次进行加锁操作,能够成功的(锁没人使用)
- 2.第二次进行加锁,此时意味着锁对象是已经被占用的状态,第二次加锁就会触发阻塞等待
要解除阻塞,需要往下执行,要往下执行就需要第一次的锁释放,这样的问题就称为"死锁"
但在java中,synchronized引入了可重入的概念
可重入是当某一个线程针对一个锁加锁成功之后,后续线程再次针对这个锁进行加锁不会触发阻
塞而是直接往下走,但是如果其他线程尝试加锁就会正常阻塞
可重入锁的实现原理关键在于让锁对象内部保存,当前是哪个线程持有这把锁,后续有线程针对这
个锁加锁的时候对比一下,锁持有者的线程是否和当前加锁的线程是同一个
如何自己实现一个可重入锁?
- 1.在锁内部记录当前是哪个线程持有的锁,后续每次加锁都进行判定
- 2.通过计数器记录当前加锁的次数,从而确定何时真正进行解锁
关于死锁
出现情况
- 一个线程一把锁,连续加锁两次
java
public void add(){
synchronized(this) {
count++;
}
Thread t=new Thread(()->{
for (int i = 0; i < 500000; i++) {
synchronized (counter2){
counter2.add();
}
}
});
- 两个线程两把锁每个线程获取到一把锁之后,尝试获取对方的锁
java
public static void main(String[] args) throws InterruptedException {
Object lock1=new Object();
Object lock2=new Object();
Thread t1=new Thread(()->{
System.out.println("t1线程开始");
synchronized (lock1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2){
System.out.println("t1线程结束");
}
}
});
Thread t2=new Thread(()->{
System.out.println("t2线程开始");
synchronized (lock2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock1){
System.out.println("t2线程结束");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}


两个线程因为竞争的缘故而阻塞了
如何避免死锁?
首先我们得知道死锁是如何构成的?
死锁的构成
- 1.锁是互斥的(锁的基本性质)
一个线程拿到锁之后,另一个线程再尝试获取锁,必须要阻塞等待
- 2.锁是不可抢占式的(不可剥脱)
线程1拿到锁线程2也尝试获取这个锁,线程2必须阻塞等待,而不是线程2直接把锁抢过来
- 3.请求和保持
一个线程拿到锁1之后,不释放锁1的前提下,获取锁2
代码中加锁的时候,不要去"嵌套"
- 4.循环等待
多个线程,多把锁之间的等待过程构成了"循环"
A等待B,B也等待A。对于这种情况我们可以通过约定好加锁的顺序,就可以破除循环等待
约定
约定就是每个线程加锁的时候永远是先获取序号小的锁后获取序号大的锁
死锁的小结
1.构成死锁的场景
- a)一个线程一把锁=>可重入锁
- b)两个线程两把锁=>代码如何编写
- c)N个线程M把锁=>哲学家
2.死锁的四个必要条件
- a)互斥
- b)不可剥夺
- c)请求和保持
3.如何避免死锁
打破c)和d)
- c)->把嵌套的锁改成并列的锁
- d)->把锁的顺序做出约定
Java 标准库中的线程安全类
部分数据结构和集合类存在线程不安全的情况
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
以上集合类自身没有进行任何加锁限制,但还是有一些线程安全的,使用了一些锁机制来控制
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
上面四个关键方法加了synchronized
内存可见性
内存可见性是造成安全问题之一
java
private 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 sc=new Scanner(System.in);
System.out.println("请输入flag的值:");
flag=sc.nextInt();
});
t1.start();
t2.start();
}

一个线程读取,一个线程修改,修改线程修改的值,并没有被读线程读到
分析没有被读到的原因
在执行过程中,JVM就能感知到load反复执行的结果好像都一样
load是读内存操作
cmp是纯cpu寄存器操作
load的时间开销是cmp的几千
对于JVM反复读,JVM会把读取内存的操作优化成读取寄存器这样的操作(把内存的值读到寄存器
了,后续再load不在重新读内存,直接从寄存器里来取)
volatile
对于上述问题,Java语法中引入volatile关键字,通过这个关键字修饰某个变量,此时编译器这个
对这个变量的读取操作就不会被优化成读寄存器


volatile解决内存可见性问题,不是原子性问题
谈到volatile -> JMM(Java Memory Model,Java内存模型)