一:引入
代码演示线程安全问题:200000 次自增多线程运算
public class ThreadDemo12 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:" + count);
}
}
运行三次的结果:
通过执行代码发现最终结果和我们所预想的结果不同,而且相同代码运行三次有三个不一样结果,所以猜想此进程应该是出现了线程安全问题。
二:线程不安全的原因分析
1.抢占式执行
线程不安全的根本原因。多线程是并发执行的,操作系统负责分配 CPU 时间给各个线程,一旦当前的线程使用完分配给自己的 CPU 时间,操作系统将决定下一个占用 CPU 时间的是哪一个线程,把 CPU 分配给在等待队列的下一个线程,每个线程占用 CPU 的时间取决于进程和操作系统。
讨论线程安全问题,还要考虑Java并发编程的三大基本特性:原子性,可见性和有序性
2. 原子性
解释:一个操作是不可分割的,要么全部执行成功,要么全部不执行,不存在中间状态。
与人脑思考的不同,“count++”操作对于我们来说只是一条指令,在原值的基础加一即可,但是对于CPU来讲,一共要执行三条指令:1). 从内存中加载这个变量到寄存器 LOAD;2). 对这个变量做加1操作 ADD;3). 再把这个结果从寄存器写回内存STORE。
CPU执行 “count++” 时只能保证一条指令是原子的,但是这条指令执行完了,是接着执行该操作的下一条指令还是另一个操作的某条指令是未知的。
画图理解:
两个线程中三条指令可能执行的顺序举例:
具体推演:
两个寄存器:由于这两个线程是并行执行还是并发执行,我们并不知道。但是即使是并发执行,在一个 CPU 核心上,两个线程有各自的上下文(各自一套寄存器的值, 不会相互影响)。
经过上述推演对比发现:最关键的问题在于进程要确保第一个线程 save 了之后,第二个线程再load,这个时候第二个线程 load 到的才是第一个线程自增后的结果。否则,第二个线程 load 到的就是第一个线程自增前的结果了,如此,我们相当于只执行了一次自增。
3. 可见性
解释:一个线程对共享变量的修改能够及时地被其他线程看到。
在JAVA中,一个进程开始运行的时候要去操作系统中申请内存空间,这个进程中的所有线程共享这个内存空间,每一个线程在读取和修改变量时,必须把这个变量从主内存(共享)中读取到自己的工作内存中,修改完了再把新数据从工作内存写回主内存。
在上述图示举例中,可见性问题就是因为 t2 线程执行完 STORE 指令后,主内存中的 count 值发生改变,但是 t1 线程早就执行了 LOAD 操作,导致 t1 线程无法看见并获得更新后的数据,由此就造成了内存可见性问题。
其他代码举例:
public class ThreadDemo23 {
private static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
//想要执行的逻辑
}
System.out.println("t1 is over");
});
Thread t2 = new Thread(() -> {
System.out.println("请输入 flag 的值: ");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
期待运行结果:当输入一个非 0 的 flag 值,t1 线程结束。
实际运行结果:
分析代码:其中 t1 线程负责读操作,t2 线程负责写操作,当我们改变了 flag 时,t1 线程似乎并没有 “看到” 更新后的值。
主内存:计算机的内存
工作内存:CPU 的寄存器以及L1 L2 L3 一二三级缓存
4. 有序性
解释:程序执行的顺序按照代码的先后顺序执行,不会乱序执行。
程序员代码水平都不一样,写编译器的大佬考虑到这个问题,会对代码质量不高的代码进行优化,逻辑之间不相关的代码的执行顺度可能会发生改变。
比如:
三:解决方法
既然知道问题所在,那我们应该如何解决问题呢?
其中,抢占式执行这个问题是无法避免的,只能尽可能解决。
1. synchronized
对代码进行加锁,将 “ count++ ” 这个操作锁起来,这样就可以确保 CPU 在执行三条指令时不会有别的线程插入。这个操作就相当于把三条指令打包,使其具有原子性。
public class ThreadDemo12 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
//创建一个对象,只有对象相同,所才会发生竞争
Object lock = new Object();
//Object lock2 = new Object();如果另一个锁的对象是lock2,两个锁就不会竞争,跟不加锁的效果是一样的,线程并发
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (lock) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (lock) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:" + count);
}
}
//运行结果为 200000
2. volatile 关键字
作用:
1)解决内存可见性问题:当写一个 volatile 变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,每次访问变量必须重新读取主内存,而不是自动优化从寄存器或缓存中读取。
2)禁止指令重排序:加入volatie关键字时,会多出一个 lock 前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障针对这个 volatile 关键字修饰的变量的读写操作相关指令是不会被重排序影响的。
解决内存可见性:
private volatile static int flag = 0;
运行结果:
四:总结
标签:count,Thread,++,t1,安全,线程,内存,解决 From: https://blog.csdn.net/m0_73659744/article/details/1423498431. 线程安全的根本原因是并发编程的抢占式执行,直接原因是原子性问题。
2. synchronized 关键字解决的时原子性问题,volatile 关键字解决的是可见性问题和有序性问题,此处有序性问题指的是指令重排序。
3. 内存可见性和指令重排序其实都是编译器对代码做出的优化。在单线程进程中对代码的优化作用还是可观的。