多线程-线程安全
摘要: 本文介绍了Java线程状态和线程安全问题。Java线程有6种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。线程安全问题的根本原因是线程随机调度和共享变量修改冲突。解决方法包括synchronized锁和volatile关键字。synchronized通过加锁实现互斥访问,具有可重入性,可避免死锁;volatile保证内存可
一、线程状态
Java 给线程引入了六种状态
| 线程状态 | 含义说明 |
|---|---|
NEW |
安排了工作,还未开始行动 |
RUNNABLE |
可工作的,又可以分成正在工作中和即将开始工作 |
BLOCKED |
这几个都表示排队等着其他事情 |
WAITING |
这几个都表示排队等着其他事情 |
TIMED_WAITING |
这几个都表示排队等着其他事情 |
TERMINATED |
工作完成了 |
- NEW:创建了Thread对象,但是还没有调用start;
- TERMINATED:操作系统内部的线程已经销毁了,但是Thread对象还在,线程的入口方法执行完毕。
- RUNNABLE:一个线程,正在cpu上执行,或者没有在cpu上执行,但是也在就绪队列中。
- WAITING:表示死等进入的阻塞状态,join()不待参数
- TIMED_WAITING:带有超时时间的等待
- BLOCKED:特指由于锁引起的阻塞
eg:
public class Demo13 {
public static void main(String[] args) throws InterruptedException {
Thread mainThread = Thread.currentThread();
Thread t = new Thread(() -> {
while (true){
System.out.println(mainThread.getState());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
System.out.println(t.getState());
t.start();
t.join(1000);
System.out.println(t.getState());
}
}
二、线程安全
一个进程的多个线程,共享同一份内存资源,如果两个线程,都尝试修改某个变量,就可能出现冲突;
某个逻辑单个线程执行是可以的,但是多个线程执行出现问题,这就是线程不安全,反之则线程安全
- 线程安全问题的原因
- [ 根本原因 ] 操作系统对于线程的调度是随机的(没有办法应对)
- 两个线程针对同一个变量进行修改操作
- 修改操作不是原子的
- 内存可见性
- 指令重排序
eg:线程不安全例子
public class Demo14 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
//创建两个线程,分别对同一个变量进行5w次 ++ 操作
//最终主线程打印结果
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 = " + count);
}
}
使用两个线程分别对同一个变量进行5w次 ++ 操作,其结果应该为10w,但运行结果确像是一个<10w的随机值,这就是两个线程对同一变量修改的不安全。count++操作实际是三次指令,将内存值加载到cpu寄存器中,在cpu寄存器中对值进行计算,将寄存器再写入到内存中,由于是三次指令,可能在某一条指令时调度到别的线程,这样的调度穿插过程就可能出现线程安全问题。
2.1 保证线程安全的方法 - 锁
2.1.1 synchronized 关键字 - 监视器锁
锁当前对象
synchronized (obj){
代码块
}
(括号中的对象:从语法角度来说可以是任何对象,只要是Object的实例即可;从语义角度来说,两个线程填写相同的对象才会有锁竞争,才会有阻塞效果。)
要点:
- 进入synchronized { 中的代码块就是加锁,退出 } 就是解锁
- 加锁操作是防止其他线程" 插队 ",不影响本线程调度出cpu
- 锁对象,两个线程针对同一个对象加锁才会有锁竞争,锁不同的对象则不会有。
2.1.2 synchronized 的其他写法
- 修饰一个实例方法
synchronized public void add(){
count++;
}
public void add(){
synchronized(this){
count++;
}
}
此时锁的this对象就是调用add方法的对象
- 修饰一个静态方法(针对类对象)
private static void add(){
synchronized (Demo16.class){
count++;
}
}
无论哪种写法,synchronized 方法针对啥对象加锁不重要,重要的是两个线程是否针对同一个对象加锁!
2.1.3 synchronized 不存在死锁情况
synchronized 对同一线程具有可重入性,不会存在把自己锁死的情况。
- 理解锁死:一个线程加锁后没有释放锁就再次进行加锁,此时会产生阻塞等待,只到第一次的锁被释放。但是释放锁也是由该线程来完成的,此时该线程已经阻塞了无法进行释放锁,就会一直持续阻塞,此时逻辑就会"卡死",形成死锁。
- 但是Java中的synchronized 并没有上面的情况,如果第一次加锁成功后,在进行第二次加锁,synchronized 内部会判定第二次加锁的线程是否和第一次加锁是同一个线程,如果是同一个,第二次加锁相当于直接跳过,不做任何处理;如果不是同一个线程,第二次加锁才会真正生效。
死锁的场景:
- 一个线程一把锁,连续加锁两次(可重入锁直接解决)
- 两个线程两把锁,相互获取对方的锁(eg:车钥匙锁屋子里,屋钥匙锁车里)
- n个线程m把锁
死锁的四个必要条件 (打破任意一个就可以避免死锁)
- 锁是互斥的(对于synchronized来说改不了)
- 锁不可被抢占 (对于synchronized来说改不了)
- 请求和保持
例:A线程在获取到locker1的情况下,保持持有locker1的状态(不释放),再尝试获取locker2. - 循环等待 / 环路等待
如何避免死锁?
打破请求和保持:在代码中尽量避免锁的嵌套
打破循环等待:约定加锁的顺序(把锁进行编号,约定任何一个线程多把锁的时候,都需要按照标号从小到大的顺序来加锁)
2.2 volatile 关键字
volatile 能够保证内存可见性
内存可见性是由于编译器优化导致的,编译器优化又是什么:举个例子,在程序员中写代码的水平是参差不齐的,而写的差占据多数因此就在编译器中加入了”优化机制“,编译器自动分析这一部分代码逻辑,保持代码逻辑不变的前提下,自动修改代码内容,让代码变得更加的高效。
eg:
public class Demo18 {
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 scanner = new Scanner(System.in);
System.out.println("输入 flag 的值");
flag = scanner.nextInt();
System.out.println("t2 结束");
});
t1.start();
t2.start();
}
}
- 为什么这个代码在t2线程修改了flag的值,但是t1线程并没有结束:
站在cpu指令的角度,,首先load操作从内存读取flag的值到寄存器中,然后比较寄存器和0的值是否相同,如果相同继续执行;不相同使用跳转语句跳转到指定位置。load操作的开销远远大于比较的开销。
在比较处,编译器发现flag每次读到的都是相同的值,且1s足以让这个循环执行上万次,编译器也并没有发现哪里在修改。虽然在另一个线程中有修改,但编译器无法分析出另一个线程的执行时机,此时编译器 就做了一个大胆的决定,把load的操作给优化掉了,所以后续的循环都是从寄存器/缓存中读取flag的值,提高效率,因此即使在t2中修改了flag的值,t1线程也无法感知到。
解决方案:使用volatile 关键字修饰某个变量,此时编译器就知道这个变量" 易变 ",后续编译器针对这个变量的读写操作就不会涉及到优化了。
- volatile 并没有互斥 / 原子性,适用于一个线程读,一个线程写的情况。无法应对两个线程同时写的情况。虽然和synchronized都是解决线程安全问题,但和synchronized解决的是两种不同的问题。
public class Demo18 {
private static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0){
try {
Thread.sleep(1);
} 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();
System.out.println("t2 结束");
});
t1.start();
t2.start();
}
}
加上sleep(1)后为什么代码又可以正确执行了,并不是sleep解决了内存可见性。而是因为本质上内存可见性是由编译器优化带来的,因为引入了sleep,这个代码中的load操作没有被编译器优化掉,因为sleep背后是非常多的指令,消耗的时间比load读取一次多得多,所以即使优化掉load操作也没什么太大作用。所以是sleep影响到了编译器优化,因此内存可见性没有了。
三、wait 和 notify
多线程之间是随机调度的,执行顺序难以知道,而有时我们又希望能够确定多个线程之间的先后执行顺序,而join方法只能确定线程的结束顺序,此时就需要用到wait和notify方法。除此之外,wait和notify还可以解决"线程饿死"问题。
wait(),notify(),notifyAll() 都是Object 类的方法。
3.1 wait 方法
wait 方法让当前线程进入等待状态;wait方法必须搭配synchronized来使用。
wait做的三件事情:
- 释放当前的锁
- 等待其他线程的通知(进入阻塞状态)
- 当通知到达后,从阻塞状态回归到就绪状态,并且重新获取到锁。
1和2之间必须是原子性的
3.2 notify 方法
notify 方法是唤醒等待的线程。
该方法用来通知那些可能等待该对象的线程,对其发出通知并使他们重新获取该对象的锁。如果有多个线程等待,则随机挑选一个wait 状态的线程;在notify 方法后并不会马上释放该对象锁,而是要等待执行notify 方法线程将程序执行完也就是退出同步代码块才会释放对象锁。
public class Demo20 {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1){
System.out.println("t1 wait 之前");
try {
locker1.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 wait 之后");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1){
System.out.println("t2 notify 之前");
locker1.notify();
System.out.println("t2 notify 之后");
}
});
t1.start();
t2.start();
}
}
notifyAll 方法:notify方法只是唤醒某⼀个等待线程. 使用notifyAll方法可以⼀次唤醒所有的等待线程。
3.3 wait 和sleep 的区别
都可以让线程阻塞,都可以指定阻塞的时间
- wait 的设计就是为了被 notify ,超时时间只是用来保底的;而sleep 就是用来按照一定时间进行阻塞
- wait 必须搭配锁使用,而sleep不需要
- wait 一进来会先释放锁,然后再获取到锁;sleep 放到锁内部等待时不会释放锁
更多推荐


所有评论(0)