请指点
一、什么是线程安全
1.概念
- 线程不安全:编写多线程代码的时候,如果因为系统调度线程的随机性,而引起的BUG,则称线程不安全
- 线程安全:编写多线程代码的时候,不会因为系统调度线程的随机性而引起BUG,则称线程安全
2.一个经典的案例:
class Test {
public int count = 0;
public void Increase(){
count++;
}
}
public class TestFour {
private static Test test = new Test();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
test.Increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
test.Increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(test.count);
}
}
我们本想用上述代码,通过多线程的方法实现count累加,期望的得到的结果是100000,怎料,多次实验的结果均在50000~100000之间的随机数,这就的因为线程不安全而引起的bug
count++的执行流程:
- load 把内存中的值,读到CPU的寄存器中
- add 把寄存器中的count值 + 1
- save 将count值放回到内存中
1.但是由于操作系统调度线程的随机性,就会出现不同的结果
(1). 我们所期待的线程调度顺序是这样的: 如果一直保持串行,得到的结果就是100000,极小概率事件
一次期待的 t1、t2线程 执行的count++,得到的结果是 2
(2). 但是由于系统随机调度线程,会出现这样的: 随机组合,杂乱无章,哪种情况执行了多少次也不知道(随机的) t1、t2线程
执行一次count++,得到的结果是 1 如果一次串行的都没有的话,得到的结果 是50000,极小概率事件
2.加锁操作解决这个线程不安全问题
class Test {
public int count = 0;
synchronized public void Increase(){
count++;
}
public void Increase(){
synchronized(this){
count++;
}
}
}
public class TestFour {
private static Test test = new Test();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
test.Increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
test.Increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(test.count);
}
}
二、线程不安全的原因
- 线程的抢占式执行(根本原因)
- 多个线程在修改共享的数据
- 线程针对变量的修改操作不是原子的
- 内存可见性问题
- 指令重排序
- 针对第一个原因,这是操作系统设置的,我们无能为力
- 针对第二个原因,如果一个线程修改变量(正如我们学线程前面,写的所有的代码,都是在main()这一个线程中执行的),或者是多个线程读共享的变量,或者是多个线程各自修改不同的变量,都不会出现线程不安全的问题
3.原子性
-
原子就是不可拆分的最小单位
-
正如上面的count++操作,是由load,add,save三步操作组成的,所以count++操作就不具备原子性的(不是原子的)
-
一条java语句不一定是原子的,也不一定只是一条指令
-
加锁操作本质上就是保证某段代码的原子性,把不是原子的操作打包在一起
实现的效果也叫作 同步互斥,一个线程执行这段代码,另一个线程也想调用就得等待
以上面的count++为例,加锁后,load、add、save操作在一起就是原子的了,这个三个操作不可拆分,要执行一起执行,要不执行就都不执行
4.内存可见性
- 什么是内存可见性问题
对于一个多线程共享的变量,一个线程读这个变量,另一个一个线程修改这个变量,由于编译器的优化,导致修改操作不能被读的操作感知到,这就是内存可见性问题,本质上是多线程引起了编译器对于代码的优化产生了误判
- 什么是编译器优化
编译器在编译代码的时候并不是逐字逐句地翻译代码,而是在保证代码原有逻辑不变的前提下,动态调整要执行的指令的内容,从而提高程序的运行效
率
但是要注意的是,编译器没法把多个线程中的代码联系到一起分析,把一个线程当成独立的个体看;因此,在单线程的场景中,编译器优化是准确的;在多线程的场景中,编译器优化会出现误差的
- 内存可见性问题出现在如下场景:
public class Test10 {
// 设置一个多线程共享的变量isQuit
private static int isQuit;
public static void main(String[] args) {
Thread t = new Thread(() -> {
// t线程读这个变量isQuit,如果isQuit改为 1 的话,线程结束
while(isQuit == 0){
}
System.out.println("t线程结束");
});
t.start();
// 在main线程中修改这个变量
System.out.println("请输入isQuit的值");
Scanner scanner = new Scanner(System.in);
int isQuit = scanner.nextInt();
System.out.println("main()线程结束");
}
}
运行结果可知,t 线程一直在while()循环中执行;
原因分析:
- t 线程中的isQuit==0如何执行?
load 将内存中isQiut的值读到CPU寄存器中
compare 在CPU寄存器中,将isQuit的值和0比较- main 线程修改 isQiut 的值,放到内存中
- 编译器直观的认为,t这个线程中,反复的进行了太多次的load,并且isQuit的值不会改变(编译器没法把多个线程联系在一起),认为这样太耗费时间了(从寄存器中读数据比从内存中读数据快了3~4个数量级),作了个大胆的优化,只执行一次load,不再重新读内存了,而是直接读寄存器
4.main线程修改了isQuit变量,但是 t 线程获取不到修改结果
- 如何解决内存可见性问题
- 使用synchronized加锁操作,禁止编译器在相关代码的内部进行上述的优化
public class Test10 {
// 设置一个多线程共享的变量isQuit
private static volatile int isQuit;
private static Object object = new Object();
public static void main(String[] args) {
Thread t = new Thread(() -> {
// t线程读这个变量isQuit,如果isQuit改为 1 的话,线程结束
synchronized (object) {
while (isQuit == 0) {
}
}
System.out.println("t线程结束");
});
t.start();
// 在main线程中修改这个变量
System.out.println("请输入isQuit的值");
Scanner scanner = new Scanner(System.in);
isQuit = scanner.nextInt();
System.out.println("main()线程结束");
}
}
- 使用volatile关键字 修饰变量,再次读这个变量时,禁止了编译器对 “读内存”指令的优化,而是从内存中重新读取这个变量,保证了其内存可见性
public class Test10 {
// 设置volatile关键字,设置volatile关键字,设置volatile关键字
private static volatile int isQuit;
public static void main(String[] args) {
Thread t = new Thread(() -> {
// t线程读这个变量isQuit,如果isQuit改为 1 的话,线程结束
synchronized (Thread.class) {
while (isQuit == 0) {
}
}
System.out.println("t线程结束");
});
t.start();
// 在main线程中修改这个变量
System.out.println("请输入isQuit的值");
Scanner scanner = new Scanner(System.in);
isQuit = scanner.nextInt();
System.out.println("main()线程结束");
}
}
5.指令重排序
在保证代码原有逻辑不变的情况下,重新调整了指令的执行顺序,这个和编译器优化存在关联
参考下一篇文章的单例模式,理解指令重排序
指令重排序这个问题很玄学,只是说有可能发生;编译器根据具体的情况,来看是不是需要指令重排序
三、synchronized的使用
synchronized是一个互斥锁,即一个线程使用它,另一个线程也想使用就得等待
1.synchronized的作用:
- 保证某些操作的原子性(线程的“有序性”)
- 保证内存可见性
- 不可以 禁止指令重排序
2.锁对象
也叫作加锁的对象,把锁对象看做是一个房间,synchronized操作看作是给这个房间加锁,把线程看做是要进入房间的人,synchronized修饰的代码或方法看作是人要进行的操作
- 两个线程针对同一个对象加锁(同一个锁对象),会产生竞争,会产生阻塞状态(等待锁的状态(BLOCKED))
- 两个线程针对不同的对象加锁(不同的锁对象),不会产生竞争
- Java中任何继承自Object类,都可以作为锁对象,类对象也可以的
- 加锁操作本质上是 操作Object对象头的一个标志位
3.使用方法:
- 加到普通方法上
进入方法相当于加锁,出了方法解锁
此处的锁对象相当于this,this就是当前的类 - 加到代码片段上,需要指定一个锁对象(可以是this)
- 加到静态方法上
此时的锁对象相当于类对象
- 针对同一个对象加锁
public class Test11 {
private static Object locker = new Object();
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Thread t1 = new Thread(() -> {
while(true) {
synchronized (locker) {
System.out.println("t1线程开始,输入数字");
int a = scanner.nextInt();
System.out.println("t1线程结束");
}
}
});
t1.start();
// 保证 t1 线程先运行
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t2 = new Thread(() -> {
synchronized (locker){
System.out.println("t2线程开始");
}
});
t2.start();
}
}
此处没有打印 t2 线程的日志
此时 t1 线程占用了locker这个锁对象,t2 线程尝试获取locker时,发现locker被 t1 线程占用,就处在阻塞状态(等待锁的状态)
- 针对两个对象加锁
public class Test11 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Thread t1 = new Thread(() -> {
while(true) {
synchronized (locker1) {
System.out.println("t1线程开始,输入数字");
int a = scanner.nextInt();
System.out.println("t1线程结束");
}
}
});
t1.start();
// 保证 t1 线程先运行
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t2 = new Thread(() -> {
synchronized (locker2){
System.out.println("t2线程开始");
}
});
t2.start();
}
}
两个线程不会竞争锁对象
4.可重入锁
可重入性是synchronized的一个重要的特性
如果synchronized不是可重入的,那么很容易出现“死锁”的情况
1.了解“死锁”
如果连续针对同一个对象加锁两次,并且如果锁是不可重入的话,就会出现“死锁”
class Counter{
public static int count;
synchronized public void add(){
synchronized (this){
count++;
}
}
}
如果synchronized是不可重入的,那么这就是一个“死锁”
某个线程调用add()时,第一次给Counter加锁,向下执行,第二次加锁操作时,Counter被占用了,那么线程就处于阻塞等待状态,等待Counter被释放;但是只有这个方法执行完才可以对Counter解锁,僵住了;所以这个线程一直处于一个阻塞等待的状态
2.了解可重入锁
但是,synchronized是可重入锁,不会出现“死锁”状态
可重入锁中持有两个信息:
- 当前这个锁对象被哪个线程持有了
- 当前这个锁对象被加锁了几次
当线程t 加锁成功后,后续再尝试加锁,检测到锁对象已经被 t 持有了,不会阻塞等待,只是修改了一个计数(+1),并不是真正的“加锁”
依次解锁,计数-1,计数为0,才是真的解锁了
四、volatile的使用
volatile的作用
- 保证内存可见性
- 禁止指令重排序
五、Java标准库中的类
线程不安全 | |
---|---|
ArrayList | |
LinkedList | |
HashMap | |
HashSet | |
TreeMap | |
TreeSet | |
StringBuilder |
线程安全 | |
---|---|
StringBuffer | 核心方法都是synchronized修饰 |
HashTable | 没见过 |
ConcurrentHashMap | 没见过 |
Vector | 没见过 |
六、JMM(Java内存模型)
Java内存模型
七、wait() 和 notify()
使用wait()和notify()可以在一定程度上控制线程执行的顺序
1.wait()
wait() 是 Object类 的方法,在某个线程中调用了wait()方法,这个线程就处于阻塞等待状态
1. wait()方法中所做的事情:
- 对当前的对象解锁
- 让当前线程进入等待状态
- 满足一定的条件,线程被唤醒,重新尝试获取到这个锁,继续向下执行
public class Test13 {
private static Object o = new Object();
public static void main(String[] args) throws InterruptedException {
System.out.println("yyds");
synchronized (o){
o.wait();
}
System.out.println("qust");
}
}
2. wait 结束等待的条件
- 其他线程调用 该对象的 notify() 方法
- 设置等待时间
- 其他线程调用该线程的interrupt方法,导致wait抛出InterruptedException异常
3. wait()的使用
wait() 一定要搭配synchronized使用
使用锁对象来调用wait(),如果调用wait()和锁对象不一样,抛出InterruptedException异常
public class Test13 {
private static Object o = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("yyds");
synchronized (o){
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("bye");
}
});
t.start();
Thread.sleep(3000);
t.interrupt();
}
}
当线程 t 处在等待状态,调用调用该线程的interrupt方法,导致wait抛出InterruptedException异常,结束等待状态
notify()
唤醒一个当前等待的线程
notify()也是Object类的方法,必须搭配synchronized使用
使用锁对象调用,如果调用notify()的对象和锁对象不一样,抛出llegalMonitorStateException异常
1.notify的使用
- 解铃还须系铃人,哪个对象调用了wait(),如果要唤醒这个线程,就还要用这个对象调用notify()
- 如果当前有多个线程,都在等待状态,调用一次notify(),则随机唤醒一个线程(没有“先来后到”之分)
public class Test13 {
private static Object o = new Object();
private static Object o2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.println("yyds");
synchronized (o){
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("bye");
}
});
t1.start();
// 保证 t1 先执行起来, t2 再执行
Thread.sleep(3000);
Thread t2 = new Thread(() -> {
synchronized (o){
System.out.println("notify执行前");
o.notify();
}
});
t2.start();
}
}
唤醒线程和等待线程的锁对象是相同的
notifyAll()
一次性唤醒所有的等待线程
这些线程都使用同一把锁,一次性被唤醒后,会重新竞争锁对象的
public class Test14 {
private static Object o = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (o) {
System.out.println("线程t1进入等待状态");
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程t1结束等待状态");
}
});
t1.start();
Thread t2 = new Thread(() -> {
synchronized (o) {
System.out.println("线程t2进入等待状态");
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程t2结束等待状态");
}
});
t2.start();
Thread t3 = new Thread(() -> {
synchronized (o) {
System.out.println("线程t3进入等待状态");
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程t3结束等待状态");
}
});
t3.start();
// 保证这上面这三个线程先执行起来
Thread.sleep(3000);
Thread waker = new Thread(() -> {
synchronized (o) {
System.out.println("开始唤醒");
o.notifyAll();
}
});
waker.start();
}
}
每个线程都有自己独立的一部分寄存器
“读”变量,从内存中读到自己的寄存器中,再读寄存器
“修改”变量,直接修改内存中的变量