目录
- 1. 线程的状态
- 2. 线程安全
- 2.1 线程不安全问题的原因
- 3. 线程安全中的部分概念
- 3.1 原子性
- 3.2 可见性
- 3.3 指令重排序
- 4. 解决线程安全问题
- 4.1 synchronized关键字
- 4.1.1 可重入
- 4.1.2 synchronized使用
- 4.2 volatile关键字
- 4.2.1 volatile使用
- 5. wait和notify
- 5.1 wait()方法
- 5.2 notify()方法
1. 线程的状态
- NEW: Thread对象已经有了.但是内核里的PCB还没有(还没有调用start方法)
- TERMINATED: 内核PCB没了,线程结束了,Thread对象还在
- RUNNABLE: 就绪状态(线程正在CPU上运行,或者线程正在排队)
- WAITING: 由于wait这种不固定时间的方式产生的阻塞
- TIMED_WAITING: sleep 触发的线程阻塞
- BLOCKED: synchronized 触发的线程阻塞
2. 线程安全
一个代码,在多线程环境下执行不出bug就可以视为线程安全,反之,一个代码在单线程下执行的效果与多线程下执行的效果不一样,就可以视为线程不安全
我们先举一个线程不安全的例子,来直观的观察线程不安全
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();
// 等待两个线程全部执行完毕
// 我们预期的结果是10W,但是实际上的结果一般是小于10W的
// 并且每次执行的结果都不一样
System.out.println("count:"+count);
}
2.1 线程不安全问题的原因
接下来我们介绍一下线程不安全的原因
- 罪魁祸首是在操作系统中线程是抢占式执行的,随机调度的
- 多个线程同时针对一个变量进行修改
- 修改操作,不是原子的
- 内存可见性
- 指令重排序
3. 线程安全中的部分概念
3.1 原子性
什么是原子性
可以简单理解为一段代码要么全部执行,要么全部不执行
可以把一段代码想象成一个房间,每个线程都是一个想进房间的人,如果没有任何的保护机制,当A进入房间后,B也可以进入房间,此时就会破坏A的隐私了
那么不保证原子性会产生什么问题呢?
一条Java语句不一定是原子的,也不一定只是一条指令
比如一个简单的++操作
count++这个操作,站在CPU的角度上,是通过三个指令来完成的
- load: 把数据从内存读取到cpu寄存器中
- add: 把寄存器中的数据进行+1
- save: 把寄存器中的数据保存到内存中
上述的代码在针对count进行修改的时候,单线程下并不会产生问题,但是在多线程下,两个线程的指令并不都是保持原子性执行的,这才导致了与预期不符的结果
3.2 可见性
可见性是指一个线程对共享变量值的修改,能够及时地被其他线程看到
主内存是指硬件角度的内存,工作内存则是指cpu寄存器和高速缓存
共享变量存在于主内存中, 每一个线程都有自己的工作内存,当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据,当线程要修改一个共享变量的时候,也会先修改工作内存的副本,再同步回主内存
当线程1修改了在它的工作内存中修改了共享变量a的值后,线程2的工作内存中a的值不一定会及时发生变化(因为主内存不一定能及时同步)这个时候代码就容易出现问题
3.3 指令重排序
本质上也是编译器的优化出现了问题,在单线程模式下,JVM和CPU指令集会进行优化,编译器对于指令重排序的前提是"保证逻辑不出现问题",这一点在单线程下是容易实现判断的,但是在多线程下就容易出现问题了,编译器很难在编译阶段对代码执行效果进行预测,因此指令重排序后很容易逻辑和之前不对等
4. 解决线程安全问题
4.1 synchronized关键字
synchronized一般要搭配{}代码块使用
当某个线程执行到某对象的synchronized时,被synchronized修饰的代码块就相当于被加锁了,此时其他线程也执行到同一个的synchronized就会产生阻塞等待,只有等上一个对象退出synchronized的时候,才会解锁
synchronized (锁对象) {
}
锁对象是谁并不重要,重要的是通过这个对象来区分两个线程是否竞争同一个锁,如果两个线程针对同一个对象进行加锁,就会产生锁竞争,一旦产生竞争,一个线程能拿到锁继续执行代码,一个线程拿不到锁,就只能阻塞等待,等前一个线程释放锁之后,他才有机会拿到锁
如果不是针对同一个对象进行加锁,就不会产生锁竞争
4.1.1 可重入
这里引入一个概念–死锁,顾名思义,就是两个或多个进程互相等待,谁也解不开锁
死锁的成因,涉及到四个必要条件
- 互斥使用(锁的基本特性): 当一个线程持有一把锁之后,另一个线程也想要获取到锁,就要阻塞等待
- 不可抢占(锁的基本特性): 当锁已经被线程1拿到之后,线程2只能等待线程1主动释放,不能强行抢过来
- 请求保持(代码结构): 一个线程尝试获取多把锁,先拿到锁1之后,在尝试获取锁2,获取锁2的时候锁1不会释放
- 循环等待(代码结构): 等待的依赖关系形成环了
避免死锁的核心就是破除上述任意一个必要条件
但是synchronized是可重入锁,不会出现自己把自己锁死的情况
在可重入锁的内部,包含 线程持有者 和 计数器 两个信息
如果某个线程加锁的时候,发现锁已经被人占用了,而且恰好占用的正是自己,那么仍然可以继续获取到锁,并且计数器会加一
进一步的,无论锁有多少层,都是要在最外层才能释放锁.锁对象中,不光要记录谁拿到了锁,还要记录锁被加了几次,每加一次锁,计数器就+1,每解锁一次,计数器就-1,当出了最后一个大括号{},计数器恰好减成零,此时才会真正释放锁(才能被别的线程获取到)
常见死锁情况(不可重入锁)
- 一个线程,一把锁,连续加锁两次,就会死锁
- 两个线程,两把锁,线程1获取锁A,线程2获取锁B,此时1再尝试获取B,2再尝试获取A
- N个线程,M把锁,一种典型的情况,哲学家就餐问题
4.1.2 synchronized使用
有了加锁.就可以把一组不是原子的操作,变成"原子操作"
class Counter {
synchronized public void increase() {
// 1. 修饰普通方法:锁的Counter对象
}
public void increase2() {
synchronized (this) {
// 2. 明确指定锁那个对象:锁当前对象
}
}
synchronized public static void increase3() {
// 3. 修饰静态方法: 锁的Counter类对象
}
public static void increase4() {
synchronized (Counter.class) {
// 4. 明确指定锁那个对象: 锁类对象
}
}
}
我们要重点理解的是,锁的是哪个对象,两个线程竞争锁了同一个对象的锁才会出现竞争
4.2 volatile关键字
volatile关键字修饰的变量能保证内存可见性,禁止指令重排序
代码在写入volatile修饰的变量的时候:
改变线程工作内存中volatile变量副本的值,将修改后的副本的值从工作内存刷新到主内存
代码在读取volatile修饰的变量的时候:
从主内存中读取volatile变量的最新值到线程的工作内存中,从工作内存中读取volatile变量的副本
结合上面的图就可以看出,每次读取值的时候,都是最新的准确的变量的值,volatile保证了内存可见性,不过volatile强制读取内存,会比直接访问工作内存要慢很多,为了数据的准确性而牺牲了速度
4.2.1 volatile使用
public static int isQuit = 0;
//public static volatile int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
// do nothing
}
System.out.println("线程1结束");
});
t1.start();
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
isQuit = scanner.nextInt();
});
t2.start();
// 当我们输入0的时候,线程并不会结束,谪显然是一个bug
}
这个例子就是典型的内存可见性问题,那为什么会产生内存可见性呢?
- load读取内存中isQuit的值到寄存器中
- 通过cmp指令比较寄存器的值是否是0,来决定是否该继续循环,但是由于while循环的非常快,短时间内就会进行大量load和cmp操作
- 此时编译器/JVM就发现,虽然进行了很多次的load操作.但是每次load操作的结果都是一样的,并且load操作又是比较费时间的,一次load操作花的时间相当于上万次的cmp了
- 所以编译器做了一个大胆的决定,只有第一次循环的时候,才读了内存,后续就不在读内存了,而是直接从寄存器中取出isQuit的值
我们只需要给isQuit加上volatile关键字,就能解决这个问题了
5. wait和notify
由于线程是抢占式执行的,无法保证线程执行的先后顺序,但是在实际开发过程中,我们需要合理协调多个线程的执行先后顺序
5.1 wait()方法
wait要做的事情:
- 释放当前的锁(释放锁的前提是先加上锁),把线程放到等待队列中
- 让进程进入阻塞
- 当线程被唤醒的时候,重新获取锁
wait要搭配synchronized来使用,脱离synchronized使用wait会直接抛出异常
wait结束等待的条件:
- 其他线程调用该对象的notify方法
- wait等待时间超时(wait方法提供一个带有timeout参数的版本,来制定等待时间)
- 其他线程调用该等待线程的interrupt方法,导致wait抛出异常
Object object = new Object();
synchronized (object) {
System.out.println("wait之前");
// 把wait要放到synchronized里面来调用,保证确实是拿到了锁
object.wait();
// 这里wait之后就会一直等待下去,这个时候就是用到了另一个唤醒方法notify
System.out.println("wait之后");
}
5.2 notify()方法
notify方法要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其他线程,唤醒等待的线程并使他们重新获取该对象的对象锁
如果有多个线程等待,则有线程调度器随机挑选出来呈wait状态的线程(并没有先来后到的规矩)
在notify方法后,当前线程不会马上释放该对象锁,要等到执行notify方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
public static void main(String[] args) {
Object object = new Object();
Thread t1 = new Thread(() -> {
synchronized (object) {
System.out.println("执行之前");
try {
object.wait();
//1. 释放当前的锁
//2. 让线程进入阻塞
//3. 当线程被唤醒的时候,重新获取锁
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行之后");
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (object) {
System.out.println("进行通知");
// 进行通知后,才会打印线程1中的 "执行之后"
object.notify();
}
});
t1.start();
t2.start();
}
notify只是唤醒某一个在等待的线程.此外还有notifyAll方法,可以一次性唤醒所有等待的线程. 虽然是同时唤醒多个线程,但是多个线程之间还是需要竞争锁,所以并不是同时执行,而是仍有先后的执行