线程安全问题及解决方案
引言
在多线程编程中,线程安全是一个至关重要的概念。当多个线程并发操作共享资源时,若处理不当,很容易出现与预期不符的结果,这就是线程安全问题。本文将详细分析线程安全问题的成因,并介绍如何通过synchronized关键字解决这一问题。
线程安全问题的现象
观察以下代码,我们预期最终count
的结果应为 100000,但实际运行结果却往往小于这个值:
private static int count=0;public static void main(String[] args) throws InterruptedException {Thread t1=new Thread(()->{for (int i = 0; i <50000 ; i++) {count++;}System.out.println("t1结束");});Thread t2=new Thread(()->{for (int i = 0; i <50000 ; i++) {count++;}System.out.println("t2结束");});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
我们预想最后count的结果应该是100000,但是运行结果却不是100000。
这个实际运行效果和预期效果不一样,这就是bug。
在多线程下,由于多线程的执行,导致出现的bug,统称为线程安全问题,或者叫做“线程不安全”。
如果一个代码,在多线程并发执行的情况下,也不会出现类似的bug,这样的代码就叫“线程安全”。
这个问题的原因就在于“count++”这里,这是因为站在CPU指令执行的角度,这一行代码实际上对应了3条CPU指令。
1.load,这是把内存中的数值加载到CPU的寄存器中。
2.add,这是让寄存器中的数值增加1。
3.save,这是把寄存器中的数据写回到内存中。
这三个操作在单线程执行的情况下不会出现问题,但是在多个线程并发执行的情况下就有可能会出现问题,原因如下图所示。
下图表示t1先执行load和add和操作,然后t2执行三个操作,最后t1再执行save操作。
这只是其中一种情况,也只涉及到了这两个线程,实际上。这些操作在这行的时候,由于线程调度是随机的,这中间也可能去调度执行其他线程。
情况有很多种,经过运行我们可以发现:
只有当一个线程的load在另一个线程的save操作之后,所得到的结果才是正确的。
接下来我们对刚刚的代码稍作修改:
private static int count=0;public static void main(String[] args) throws InterruptedException {Thread t1=new Thread(()->{for (int i = 0; i <50 ; i++) {count++;}System.out.println("t1结束");});Thread t2=new Thread(()->{for (int i = 0; i <50 ; i++) {count++;}System.out.println("t2结束");});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
此时我们得到的结果就是预想的结果,那么这就可以说明这个线程安全问题就不存在了吗?
其实并不是,这是因为我们把50000改为了50,每个线程运行的时间就大大减少了。
比如先执行的t1线程,这就有可能在t2线程执行之前,t1线程就已经执行结束了,那么此时就变成了串行执行,所以运行结果是符合我们的预期的。
线程安全问题的成因
- 操作系统对于线程的调度是随机的,是一种抢占式执行。(这也是根本原因)
- 多个线程同时修改同一个变量。
- 修改的操作并不是原子的。
- 内存可见性:
- 指令重排序。
上述示例中,问题主要源于前三个因素。当两个线程的指令交错执行时,可能导致某些修改操作被覆盖,从而使最终结果偏小。
解决线程安全问题:使用 synchronized 关键字
解决线程安全问题的有效方法之一是使用synchronized关键字进行加锁操作,确保关键代码块的原子性执行。
具体实现是使用该关键字搭配代码块来实现预期效果
基本用法
synchronized关键字可搭配代码块使用,格式如下:
synchronized(){
执行一些要保护的逻辑
}
进入上述的代码块就相当于是加锁,出了代码块就相当于是解锁。
上面的括号中我们用来填写要加锁的对象。
在Java中,任意一个对象都可以用来当作“锁”。
下面我们用这个方法来解决上述线程安全问题。
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 < 50000; i++) {synchronized (locker) {count++;}}System.out.println("t1结束");});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}System.out.println("t2结束");});t1.start();t2.start();t1.join();t2.join();System.out.println(count);
}
需要注意的是,这两个线程必须要针对同一对象进行加锁,才会产生互斥效果,从而解决线程安全问题。
也就是说,如果两个线程加的是同一把锁,那么当一个线程正在进行的时候,另一个线程就得阻塞等待,得等到正在运行的线程运行结束(释放锁),这个线程才能开始执行。
综上所述,用这个方法解决线程安全问题,仅仅写一个synchronized关键字是不行的,还要做到以下两点。
- synchronized()的代码块要正确且合适。
- synchronized后面的括号内指定的锁对象也要合适。
我们这种写法,只有每次count++之间是串行的,.这个for循环和i++仍然是并行的,执行速度较快。
若将synchronized放在 for 循环外部,则会导致整个循环串行执行,效率较低:
Thread t1=new Thread(()->{synchronized (locker){for (int i = 0; i <5000 ; i++) {count++;}}System.out.println("t1结束");});Thread t2=new Thread(()->{for (int i = 0; i <5000; i++) {synchronized (locker){count++;}}System.out.println("t2结束");});
这种写法的执行时间更长,只有t1的五万次循环都执行完只会,t2才会开始执行。这就相当于是完全的串行。
用synchronized来修饰方法
除了上面这种写法,我们也可以用synchronized来修饰方法,示例如下:
class Counter{private int count=0;synchronized public void add(){count++;}public int get(){return count;}}public class Demo2 {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// synchronized (locker) {counter.add();// }}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// synchronized (locker) {counter.add();// }}});t1.start();t2.start();t1.join();t2.join();System.out.println("count=" + counter.get());}
}class Counter{private int count=0;synchronized public void add(){count++;}public int get(){return count;}}public class Demo2 {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// synchronized (locker) {counter.add();// }}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// synchronized (locker) {counter.add();// }}});t1.start();t2.start();t1.join();t2.join();System.out.println("count=" + counter.get());}
}
上面的add方法也可以采用下面这种写法:
public void add(){synchronized(this){count++;}}
考虑到有些方法是被static所修饰的,那么这些方法中用不了this关键字,那么此时 synchronized修饰这些方法,相当于针对类对象加锁。
public static void func(){synchronized (Counter.class){}}
public synchronized static void func(){}
这两种写法所实现的效果是一致的,只是在写法上有所差别。
总结
线程安全问题是多线程编程中常见的挑战,其根源在于操作系统的抢占式调度机制以及非原子操作对共享资源的并发修改。通过synchronized关键字,我们可以对关键代码块或方法进行加锁,确保其原子性执行,从而有效解决线程安全问题。
在实际开发中,合理使用synchronized需要注意两点:一是代码块的范围要恰当,二是锁对象的选择要正确。只有这样,才能在保证线程安全的同时,尽可能减少对程序性能的影响。