关于线程安全
在多线程下,发现由于多线程执行,导致的bug,统称为"线程安全问题"如果某个代码,在单线程执行没有问题,在多线程下执行也没有问题,那么,就称之为"线程安全",反之则称为"线程不安全"
代码示例
class Counter {
public int count = 0;
//通过变量捕获进行对count进行修改,如果是局部变量,则不能进行局部变量修改
void increase() {
count++;
}
}
public class demo3 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
通过上述的代码,我们可以发现,每次count的值都与我们预期的值不相同,对于这种现象,我们称为"bug".在多线程中执行导致的bug,统称为"线程安全问题"
其实出现bug的原因也很简单,通过上述代码我们可以发现我们每次都调用到了Counter类中的increase方法中的count++这个操作.
站在cpu指令的角度,count++本质上其实是3个步骤:
1.把内存中的数据加载到CPU的寄存器中(load)
2.把寄存器中的数据进行+1(add)
3.把寄存器中的数据写回到内存中(save)
如果上述的操作,在两个线程,或者多个线程并发执行的情况下,就可能会出现问题.
在左侧的图中首先的操作就是将count加载到t1的工作内存(寄存器)中,随后count变量也加载到t2中,t1在工作内存中修改count的值,t2也修改了count的值返回到内存中,count的值为1,最后t1中count值返回到内存中,count的值最终为1.虽然是自增两次,但是两个线程并发执行,就可能在一定的执行顺序下,导致运算的中间结果就被覆盖了,与我们预想的值不相同,而且得到的错误值一定是小于1w的,而在右侧的图中,代码的顺序性是没有问题的.执性的顺序是串行与我们预期值一样.在这5000次的循环中,有多次是这俩线程串行执行++的,不确定,因为线程的调度是随机的,抢占式的执行过程.
虽然一个cpu核心上寄存器只有一组但是多线程下,可以看作各自都有一组寄存器,本质上是"分时复用".
- 线程之间的共享变量都存储在主内存中
- 每个线程都有自己的"工作内存(寄存器)"
- 当线程要读取共享变量的时候,会先把变量从主内存拷贝到工作内存(寄存器),再从工作内存中读取数据
- 当线程要对一个共享变量进行修改的时候,会先修改工作内存中的副本(寄存器),在同步回主内存
由于每个线程都有自己独立的工作内存,这些工作内存相当于同一个共享变量的"副本",修改线程t1的工作内存值,t2不会及时发生改变.
线程安全问题的原因
1.线程调度性[根本原因]:多个线程之间的调度顺序是"随机的",操作系统使用"抢占式"执行的策略来调度线程,和单线程不同的是,多线程在执行顺序产生了更多的变化.以往单线程只需要在固定顺序下执行,现在则要考虑多线程下,N种执行顺序都要正确.
2. 修改共享数据(代码结构):多个线程同时修改同一个变量,容易产生线程安全问题.
3. 原子性:原子性的意思就是我们把一段代码想象成一个房间,每个线程就是要进入房间的人,如果房间没有任何的机制保护.假设现在由线程A进入房间,A还没有出来,线程B也可以进入房间,这样就打断了A在房间里面的隐私,这就是不具备原子性的,
那么我们其实可以把代码设置为原子性的,只需要房间里加上一把锁,当A线程进去后,给房间加上一把锁,其他线程就不会打断线程A,只有当A把锁解开的时候,其他线程才能进入房间.有时候也把这个现象叫做同步互斥,表示操作时互相排斥的.
一条Java语句不一定是原子的,也不一定只是一条语句,就比如我们刚才所看见的count++实际上是由三步操作完成的:1.从内存把数据读到寄存器2.进行数据更新3.把数据写回到内存种
4. 可见性:一个线程对共享变量值的修改,及时地被其他线程看见
5. 指令重排序:在多线程环境下,cpu和编译器会对代码进行重排序,但重排序的结果并不会影响单线程程序的执行结果,而在多线程环境下会受到影响
解决线程安全问题
//解决线程执行调度顺序,就需要把代码设置为原子性的,只需要给代码加上一把锁,synchronized(锁)
class Counter {
public int count = 0;
//通过变量捕获进行对count进行修改,如果是局部变量,则不能进行局部变量修改
//进入方法就会加锁(lock)
//出了方法就会解锁(unlock)
synchronized void increase() {
count++;
}
}
public class demo3 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();//加锁成为串行
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
//通过加锁操作,把并发执行->串行执行,但是不是整体的串行,for循环并没有涉及到线程安全问题,
//for循环中的i是栈上的一个局部变量,两个线程是有两个独立的栈空间,也就是完全不同的变量.
//两个线程中的i不是同一个变量,两个线程修改了两个不同的变量,没有线程安全问题,也就不需要加锁
当t1加锁后,t2如果也尝试加锁,就会陷入阻塞等待,这个阻塞会一直等到t1把锁释放后,t2才能加锁成功.就把"穿插执行"变成了"串行执行".