首先我们需要明白操作系统中线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时线程的执行顺序是不确定的,有一些代码执行顺序不同不影响程序运行的结果,但也有一些代码执行顺序发生改变了重写的运行结果会受影响,这就造成程序会出现bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题。
public class ThreadDemo9 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
}
class Counter {
private int count = 0;
public void add() {
count++;
}
public int get() {
return count;
}
}
预期结果:100000
为什么两个线程分别自增5w次,而结果不是10w呢?
一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性的
多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性
counter.add() 我们可以把这句话拆分成3个操作:
由于线程执行的随机性,这三步操作可能存在交叉执行(一个正在add,一个正在load)
如果我们通过代码让三步操作固定在一起,就能解决问题了
你在房间中使用ATM时,你把门锁住,其他人就不能进来
你使用完房间后,解锁,这个时候其他人都能进入房间
在java中最常用的加锁操作就是使用synchronized
关键字进行加锁
互斥
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
- 没有抢到锁的线程阻塞等待,参与下一次的锁竞争
刷新内存
synchronized的工作过程:
- 获得互斥锁
- 从主存拷贝最新的变量到工作内存
- 对变量执行操作
- 将修改后的共享变量的值刷新到主存
- 释放互斥锁
可重入
synchronized是可重入锁
同一个线程可以多次申请成功一个对象锁
某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。
public class SynchronizedDemo {
synchronized public void methond() { }
}
public class SynchronizedDemo {
public void method() {
synchronized (this) { }
}
}
这里的this可以换成任意一个Object类对象,效果和修饰普通效果一样
public class SynchronizedDemo {
synchronized public static void method() { }
}
public class SynchronizedDemo {
public void method() { synchronized (SynchronizedDemo.class) { }
}
}
谁调用被synchronized修饰的方法或者类,谁就是锁对象
这里的counter都在调用add方法,counter就是锁对象
public void add() {
synchronized(this){
count++;
}
}
保证counter.add()中的三步操作同时执行,最终结果才能正确。
join与加锁的区别:
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(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
通过执行结果可以看到,我们输入一个数字后,程序并不会结束。
该程序执行的主要两个步骤
- load 从内存中读取数据到寄存器
- cmp 比较寄存器的值是否是0
由于while循环体为空,执行速度很快,远远超过比较的速度,这样就导致每次读出来的值都是0,所以编译器就主动进行优化,认为load读出来只会是0,就导致只会进行一次读数据,后面一直进行比较。这也是为什么即使我们输入了非0的数,也不能停止程序
编译器优化
在保证程序结果不变的前提下(多线程不一定),通过加减语句等操作让程序效率提升。
要想解决这个问题,我们只需要让编译器不主动的优化
让编译器不优化其修饰的变量,每次都从内存重新读取
工作内存:寄存器+缓存
拓展:
CPU缓存:读取速度介于读寄存器和内存之间
cpu读数据顺序:寄存器=》缓存1=》缓存2=》缓存3=》内存
public class ThreadDemo14 {
volatile 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(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
指令重排序:在保证整体逻辑不变的前提下,调整代码执行顺序,提升效率。
调整后:
线程不安全的原因:
线程是抢占式的执行,线程间的调度充满了随机性
多个线程对同一个变量进行修改操作
对变量的操作不是原子性的
内存可见性导致的线程安全
指令重排序也会影响线程安全