“你好 世界”
在多线程编程中,线程安全性是一个关键问题。随着应用程序对并发处理的依赖增加,开发者面临着共享资源访问和数据一致性等挑战。线程安全问题可能导致程序崩溃和数据损坏。因此,了解其根本原因及有效解决方案,对于构建可靠的应用至关重要。本文将简要探讨线程安全问题的常见原因,并提供实用的解决方案。
下面我们先来看一个代码案例:
public class Demo1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
count++;
}
});
Thread t2 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
大家觉得,上面的输出的结果多少呢?依照我们以往的思维,是不是感觉就是40000.下面我们来看看输出的结果!
这是我运行四次的结果,每一次都是不一样的,那这是为什么呢?我们把上述多个线程并发执行引起的bug,称为“线程安全问题”或者“线程不安全”。那么下面一起来了解一下,引起这样bug的原因吧。
线程不安全的原因
1.线程调度是随机的,是“抢占式执行的”
这是线程安全问题的 “罪魁祸首”,随机调度使⼀个程序在多线程环境下, 执行顺序存在很多的变数,作为程序员,我们必须要保证,在任意执行顺序下,代码都能正常工作。
2.多个线程同时修改一个变量
上面的线程不安全的代码中,线程t1和线程t2同时修改了共享的count变量,它是一个“共享数据”。
3.原子性
什么是原子性?
在计算机科学和数据库管理中,原子性指的是一个操作或事务要么完全执行,要么完全不执行。在原子性中,操作被视为一个不可分割的单元。不可分割,说明这个操作在执行时,不能不其打扰。
那么上面的代码中,有被打扰的过程吗?答案是一定的!!!
上面的count++虽然是一条java语句,但是操作系统中却不是一条指令。一条指令,就是CPU上的不可分割的最小单位,CPU在进行调度切换线程的时候,势必会确保执行完毕一条完整的指令。
一条java语句不一定是原子的,也不一定只是一条指令!
上述的count++语句,其实是由三步组成的:
- 把内存中的数据,读取到CPU寄存器中(读取count的值) load指令
- 把CPU寄存器里的数据+1(将值加一) add指令
- 把寄存器的值,写回内存(将结果写回到count) save指令
那么接下来我们就可以来探讨为何上面的值出现如此现象了!
上图中,只是一种可能的调度顺序由于调度过程中是“随机”的,因此就会产生很多其他的执行顺序。由于循环执行2w次过程中,也不知道有多少次的执行顺序,是正确的情况,有多少次是其他出错的情况,因此最终结果是一个不确定的值!
4.内存可见性
它指的是一个线程对共享变量的修改对其他线程的可见性。由于现代计算机的优化和缓存机制,线程可能会在自己的本地缓存中读取和写入数据,而不是直接与主内存交互,这可能导致其他线程无法看到这些修改。(可⻅性指, ⼀个线程对共享变量值的修改,能够及时地被其他线程看到.)
线程之间的共享变量存在 主内存(Main Memory)
每一个线程都有自己的“工作内存” (Work Memory)
当一个线程去读取共享变量的时候,就会把内存中的值拷贝一份到自己的工作内存中,然后再从工作内存中读取数据。
当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,然后再同步到内存中。
由于每个线程都有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的“副本”,此时修改线程1的工作内存中的值,线程2的工作内存不一定会及时变化。
5.指令重排序
指令重排序是现代处理器和编译器为了提高性能而采用的一种优化技术。在多线程环境中,指令重排序可能导致程序的执行顺序与代码的书写顺序不一致,从而引发难以察觉的错误,尤其是在共享变量的访问中。
解决之前的线程不安全问题
对于前面代码的中出现的问题,由于count++在CPU看来并不是一个原子操作,那么如果我们把count++弄成是一个原子操作呢,那是不是就可以很好的解决了。那么此处并不是真的把count++弄成是一个原子操作,而是一个线程在count++在执行过程中,不能被其他线程打扰。那么在我们现实生活中,怎样可以避免别人的打扰呢?我们可以把门锁上是吧,这样我们在门里面做任何的事情都不会被其他人所打扰。那么在代码中,是不是也可以对其count++加锁呢?这样在t1线程执行count++时,t2线程想穿插进来,就会被锁在外面(进行阻塞等待)。
synchronized 关键字 - 监视器锁 monitor lock
synchronized会起到互斥的效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象synchronized就会阻塞等待。
- 进入synchronized修饰的代码块,就相当于加锁
- 退出synchronized修饰的代码块,就相当于解锁
接下来我们就来使用这个synchronized关键字来处理上面的问题,看看该怎么使用,以及一些使用的注意事项。
public class Demo2 {
private static int count = 0;
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
此时的结果就是对的啦。
synchronized()是关键字,不是函数,后面的()并非是“参数”。()需要指定一个锁对象,可以指定任何的对象。
上述的代码有效的前提是,两个线程都加锁了,而且是针对同一个对象。
下面我们来看一看一个线程加锁,另一个线程不加锁和两个线程都加锁了,只是是不同的锁对象。
案例一:
public class Demo3 {
private static int count = 0;
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
//未加锁
count++;
}
});
Thread t2 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
//加锁了
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
案例二:
public class Demo4 {
private static int count = 0;
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
//加锁了,锁对象是locker1
synchronized (locker1) {
count++;
}
}
});
Thread t2 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
//加锁了,锁对象是locker2
synchronized (locker2) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
我们可以从运行结果上看出,结果都是不对的。
针对我们的一个线程加锁,另一个线程不加锁的情况,相当于一个人锁了门,另一个人没有锁门,那么虽然锁门的那个人不能被未锁门的人打扰(穿插进去),但是未锁门的人可以被锁了门的人打扰呀(穿插进去),所以此时也是线程不安全的。
针对我们的两个线程都加锁了,只是是不同的锁对象的情况,虽然两个线程都是加锁了的,但是是不同的锁对象,就相当于是两个不同的房间,把门锁起来各自做各自的。这种情况可能会出现值被覆盖的情况。假设两个线程修改count的值,都是拿到各自的工作内存去修改的。t1线程很快的把count值增加到了10000,然后写回到内存中,此时count的值就是10000。t2线程拿进去就增加了500,然后写回到内存,把count的值改为500,此时内存中count的值就是500了。
锁对象作用,就是用来区分,两个线程或者多个线程,是否是针对“同一个对象”加锁,是针对同一个对象加锁,此时就会出现“阻塞(锁竞争/锁冲突)”。不是针对同一个对象加锁,此时就不会出现“阻塞”,两个线程仍然是随机调度并发执行的!!!
synchronized修饰方法
上述的代码,可以改成下面这样式的:synchronized修饰方法
public class Demo5 {
private static int count = 0;
//synchronized修饰func方法
public synchronized static void func() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
func();
}
});
Thread t2 = new Thread(()-> {
for(int i = 0;i < 20000;i++) {
func();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
使用synchronized务必要明确锁对象,针对这个写法,锁对象就是this。
锁在使用的时候,存在很多的注意事项,如果使用不恰当的话,就会出现严重的bug“死锁”,那么关于死锁的问题呢,我会在后面的博客说到,大家有兴趣的话,可以留意一下哦。
再谈内存可见性:
上面我们说到,内存可见性就是一个线程修改共享变量对其他线程的可见性。下面我们来看一个代码案例,可以了解的更清楚。
public class Demo6 {
public static int n = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()-> {
while(n == 0) {
//do nothing
}
System.out.println("循环结束");
},"t1");
Thread t2 = new Thread(()-> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
n = scanner.nextInt();
},"t2");
t1.start();
t2.start();
}
}
上述的代码很简单,t2线程中输入非0的值,n就会变成非0的数了,t1线程中的循环就会结束,然后打印“循环结束”,那么我们来看看运行结果,是否是如此呢?
在我输入完4按回车之后,显示台上并没有打印“循环结束”,t1他线程并没有任何反应,通过jconsole看到t1线程仍然是持续工作的。
那么说明此时也是一个bug。对上述的结果,为什么是如此呢?这便是内存可见性的问题。
既然JVM优化了1的这个过程,是由于1的操作开销比较大,那么我们引进一个开销更大的操作,让JVM不优化1的操作了,此时会看到“循环结束”打印吗?
public class Demo7 {
public static int n = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()-> {
while(n == 0) {
try {
//睡眠10毫秒
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("循环结束");
},"t1");
Thread t2 = new Thread(()-> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
n = scanner.nextInt();
},"t2");
t1.start();
t2.start();
}
}
结果如我们所想的一样,说明加入sleep之后,上面谈到的针对1过程的优化操作,不再进行了。和读内存相比,sleep的开销是更大的,远远超过了读取内存,此时就算把读取内存这个操作给优化,也是没有意义了。
如果代码中,循环没有sleep,又希望代码能够没有bug的正确运行。此时就需要就引入了一个关键字“volatile”,修饰一个变量,提示编译器说,这个变量是“易变”的。(编译器进行上述优化的前提是,编译器认为,针对这个变量的频繁读取,结果都是固定的)
public class Demo8 {
//加入volatile
public static volatile int n = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()-> {
while(n == 0) {
//do nothing
}
System.out.println("循环结束");
},"t1");
Thread t2 = new Thread(()-> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
n = scanner.nextInt();
},"t2");
t1.start();
t2.start();
}
}
加入volatile关键字之后,编译器生成这个代码的时候,就会给这个变量的读取操作附近生成一些特殊指令,称为“内存屏障”,后续JVM执行到这些特殊指令,就知道了,不能进行上述的优化了,确保每次循环都是从内存中重新读取数据的!!!
指令重排序的情况,也会在后面的博客中展现哦,感兴趣的话,可以留意一下哦!
感谢您跟随我的旅行故事!希望这些经历能激励您探索新的目的地。如果您有任何问题或想要分享的旅行故事,请在下方留言。请继续关注我的博客,未来我将带您走访更多美丽的地方。期待与您分享更多精彩的旅行冒险!
讲到这里的话,我们这一次的相遇就到此结束了,我们下一次再见吧!