Java多线程基础-6:线程安全问题及解决措施,synchronized关键字与volatile关键字(一)

简介: 线程安全问题是多线程编程中最典型的一类问题之一。如果多线程环境下代码运行的结果是符合我们预期的,即该结果正是在单线程环境中应该出现的结果,则说这个程序是线程安全的。 通俗来说,线程不安全指的就是某一代码在多线程环境下执行会出现bug,而在单线程环境下执行就不会。线程安全问题本质上是由于线程之间的调度顺序的不确定性,正是这样的不确定性,给我们的代码带来了很多“变数”。 本文将对Java多线程编程中,线程安全问题展开详细的讲解。

原创文章首发于CSDN@碳基肥宅https://blog.csdn.net/wyd_333/article/details/130305311


一、线程不安全的样例


下面就是一个线程不安全的例子。该代码中创建了一个counter变量,同时分别创建了两个线程t1和t2,让这两个线程针对同一个counter令其自增5w次:

class Counter {
    private int count = 0;
 
    //让count增加
    public void add() {
        count++;
    }
 
    //获得count
    public int get() {
        return count;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
 
        // 创建两个线t1和t2,让这两个线程分别对同一个counter自增5w次
        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();
 
        // main线程等待两个线程都执行结束,然后再查看结果
        t1.join();
        t2.join();
 
        System.out.println(counter.get());
    }
}


按理来说,最终输出counter的结果应当是10w次。但我们运行程序后发现,不但结果不是10w,而且每次运行的结果都不一样——实际结果看起来像一个随机值:



那么这里,实际结果与预期结果不相符,就可以认为是出现了由多线程引起的bug,即线程安全问题。




二、 导致线程安全问题的原因及解决措施


1、***本质原因:线程的无序调度(抢占式执行)


线程安全问题的出现与线程的调度随机性密切相关。线程的无序调度也可以理解为抢占式执行。


线程的抢占式执行指的是在多线程系统中,操作系统会对多个线程进行调度,并且可以随时中断正在执行的线程,转而执行另一个线程。


以上述的counter代码为例。count++这一语句,本质上是由3个CPU指令构成:

  1. load。把内存中的数据读取到CPU寄存器中。
  2. add。把寄存器中的值进行 +1 运算。
  3. save。把寄存器中的值写回到内存中。


CPU需要分三步走才能完成这一自增操作。如果是单线程中,这三步没有任何问题;但在多线程编程中,情况就会不同。由于多线程调度顺序是不确定的,实际执行过程中,这俩线程的count++操作的指令排列顺序会有很多种不同的可能:




此处这俩线程的指令的排列顺序(执行先后),有很多种排列的情况


上面只给出了非常小的一部分可能,事实上实际中可能的情况是大量的。而不同的排列顺序下,程序执行的结果可能是截然不同的!我们以其中的两种可能的情况,分析它的执行过程。


注意:t1和t2是两个线程,它们可能运行在不同的CPU核心上,也可能运行在同一个CPU核心上(但是是分时复用也即并发的)。这两种情况的最终效果是一致的,我们选择复杂度相对低的情况来进行演示,即两个线程在不同的CPU核心上运行。


正常的情况


有问题的情况


而所有的指令排列情况中,实际上只有下面这两种情况能得到正确的结果:


“顺序执行”,正确的情况


因此, 由于实际中,线程的调度顺序是无序的,我们并不能确定这俩线程在自增过程中经历了什么,也不能确定到底有多少次指令是“顺序执行”的,有多少次指令是“交错执行”的。最终得到的结果也就成了变化的数值。


针对上述的counter代码,还有一些补充:最终的结果count一定是小于等于10w的,但结果不一定大于5w。参考以下情况:



t1和t2彼此都会给对方带来很多无效自增。如上面图中t2就产生了两次无效自增。不过,count小于5w的情况出现的概率是非常小的。


归根到底,线程安全问题全是因为线程的无序调度。这导致了线程中指令的执行顺序不确定,从而导致了变化的结果。可以说,线程的无需调度(抢占式执行)是真正的罪魁祸首、万恶之源!


注意:有同学可能会想到,join()能防止线程的抢占执行。不过如果用join(),线程之间是串行执行的,如果这样的话还用多线程干嘛,直接让一个线程串行执行就好了。毕竟,多线程编程的初心就是进行并发编程,更好地利用多核CPU。


2、多个线程修改同一变量(多线程修改共享数据)


这个原因有三个关键词:多个,修改,同一。


也就是说,下面这三种情况,是线程安全的:

  1. 一个线程修改同一个变量(不涉及多线程) -> 安全。
  2. 多个线程读取同一个变量(变量的值不发生变化,最终的结果没有变数) -> 安全。
  3. 多个线程修改不同的变量(各自改各自的,相互之间不影响,和第1条本质上一样) ->安全。


上面的线程不安全的代码中,涉及到了多个线程针对 counter.count 变量的修改。此时,这个 counter.count 是一个多个线程都能访问到的共享数据。


counter.count 这个变量在堆上,可以被多个线程共享访问


3、修改操作不是原子的


原子指的是不可分割的最小单位。


像上面提到的count++自增操作就不是原子的,它可以再拆分成3个操作:load,add,save。单个CPU指令,就不可再拆分。因此,如果某个操作对应单个CPU指令,那么它就是原子的(如赋值操作=);但如果某个操作对应多个CPU指令,它大概率就不是原子的。


假设一个线程正在对一个变量进行操作。中途有其他线程插入进来了,如果这个操作被打断,结果就可能是错误的。 这点也和线程的抢占式调度密切相关。如果线程不是 “抢占” 的, 就算没有原子性,也问题不大。


正是因为不是原子的,导致两个线程的指令排列存在诸多变数。如果某个操作的原子的,那么指令之间就不会插入其他的指令,指令的排列也就不会存在诸多变数;既然不存在诸多变数,那么结果也就是确定的,此时就是线程安全的。


可以通过synchronized加锁解决这个问题。


4、内存可见性问题引起的不安全


内存可见性引起的不安全问题与上面的counter代码无关,我们重新书写一个演示代码。


先书写这样一段代码:

// 预期:在t2线程中输入一个非0的数,t1线程中循环终止
public class Test2 {
    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 reader = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            flag = reader.nextInt();
        });
        t1.start();
        t2.start();
    }
}
 
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

由于两个线程共用同一块内存空间,flag作为一个内存中的变量,两个线程用的是同一个flag。


对于这段代码,预期的效果是:t1通过 flag == 0 作为条件进行循环,初始情况将进入循环;t2通过控制台输入一个整数,一旦用户输入了非0值,此时 t1 的循环就会立即结束,从而 t1 线程退出。


但当我们运行程序时会发现:当输入非0值1时,t1线程并没有结束。



通过jconsole也能观察到实际效果:t1线程仍然在执行,处于RUNNABLE状态。




这里,实际效果不等于预期效果,因此再次出现了bug,也即线程不安全问题。 这个问题的出现,就是由内存可见性引起的。


在上面的代码中,while (flag == 0) 中的这个 flag == 0 可以分为两步:load和cmp。其中,load的时间开销远远大于cmp(虽然读内存比读硬盘要快几千倍,但是读寄存器又要比读内存快几千倍)。




并且,该while循环空转的转速极快,能达到每秒钟上亿次。(这点非常重要,如果代码中有如sleep()这样大大降低循环执行速度的代码,则不会有上面bug的产生。因为根据下面编译器优化发生的条件,当循环速度(次数)下降,load就已不再是主要开销,编译器就没必要优化了。这样一来,代码是能够正常运行的。)


这时编译器发现:1、load的开销很大;2、每次load的结果都一样。于是编译器就做了一个非常大胆的操作,即把load给优化掉了(就是去掉了)。只有第一次执行load才真正执行了,后续循环都只cmp而不load了(相当于是复用了之前寄存器中的值)。


正常情况:工作内存每次都从主内存中load值


Bug情况:load这一步被取消,工作内存不再每次都从主内存中读取flag值

(这是编译器优化的手段,编译器优化是指能够智能地调整你的代码执行逻辑,在保证程序结果不变的前提下,通过加减语句、语句变换等一些列操作,让整个程序执行的效率大大提升。编译器的优化是一件非常普遍的事情。)


编译器对于“保证程序结果不变”的判定,在单线程下是非常准确的,但是在多线程的情况下就不一定了。可能导致在调整之后,虽然效率高了,但程序结果变了。即:编译器出现了误判,从而引起了bug。


总结:所谓的内存可见性问题,就是多线程环境下编译器对代码优化产生了误判,最终导致我们的代码 出现bug。可以用volatile关键字解决这个问题。


5、指令重排序引起的不安全


指令重排序,也是编译器优化的策略。在保证整体逻辑不变的情况下,编译器通过调整代码执行的顺序,让程序更高效。


比方说你去菜市场买菜,要买:1、土豆,2、黄瓜,3、鸡蛋,4、番茄 这四种菜,这四种菜在菜市场的摊位分布如下:




这时聪明的你肯定想到,既然按照清单上的购买顺序效率太低,那就不按照清单的顺序了,怎么方便怎么顺路,就怎么买~




编译器对程序的优化也是类似的。


但谈到优化,都得保证调整之后的结果和之前是不变的。单线程下容易保证,但多线程就不好说了。比如下面这个代码,就可能会因为指令重排序而出现问题:




这其中,s = new Student(); 这一操作实际上可以分成 3 步:




这个过程就好比买房:




先拿到钥匙还是先装修,问题都不大,最终都能拿到房子。


同样的,单线程中,先执行步骤2还是先执行步骤3,最终结果都是相同的。但多线程情况下,就可能出现如下问题:





可以用volatile关键字解决这个问题。




三、解决线程安全问题


1、synchronized关键字:保证修改操作的原子性


(1)什么是加锁



以上述counter代码为例:能有办法让count++操作变成原子的吗?当然是有的,这个方法就是加锁。如果给count++加锁,就能保证count++操作的原子性。


Java中虽然有读写锁,但一般不会特别去区分。默认情况下,用的就是一个很普通的加锁。


加锁的操作就好比我们在学校上厕所。有人进了厕所之后,就要把厕所门锁上,这时候他就可以在里面干一些事情而此时其他人无法进入厕所了。等到他干完了事情,再把锁打开,他就可以从厕所里出来了。当有人正在使用厕所的时候,如果其他人也想用,那么他们只能进行阻塞等待。



锁的核心操作有两个:1、加锁;2、解锁。一旦某个线程加了锁之后,其它线程也想加锁,就不能直接加上,就必须阻塞等待,直到拿到锁的那个线程释放了锁为止。




注意:线程调度是抢占式执行的,当1号老哥释放锁之后,等待锁的2号、3号、4号、5号谁能抢先一步拿到锁、成功加锁,是不确定的。 每个线程都有概率拿到锁,这完全看系统是如何调度的。


(2)如何进行加锁:synchronized关键字-监视器锁monitor lock


synchronized的读音:https://fanyi.baidu.com/#en/zh/synchronized


synchronized是Java中的关键字,可以直接使用这个关键字来实现加锁效果。



前面提到,锁的两个核心操作是加锁和解锁。此处使用代码块的方式来表示:

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁


{ } 就可以想象成厕所~

synchronized( 锁对象 ) { }



下面这张图也非常形象地表示锁竞争的情况:




注意:( )里的锁对象可以写作任意一个Object对象(是类类型即可,内置类型不行)。此处写了this,相当于将Counter counter = new Counter()的这个counter实例作为锁对象。this指向的是当前对象,add作为成员方法,观察代码可知,每次都是counter实例来调用add,this指向的就是当前对象counter。


使用this,哪个实例调用的add()就是对哪个实例加锁。



在这个代码中,线程t1和t2给同一个锁对象(this,即counter)加了锁,就会产生锁竞争。t1拿到锁,t2就得阻塞。此时就可以保证自增操作count++是原子的,不会受多线程抢占式调度的影响了。 这时再运行程序,程序的执行结果就是10w了:



时间轴图:



加锁,本质上是把并发的变成了串行的。但加锁与join()有本质区别:join()是让两个线程完整地进行串行,加锁是两个线程的某个小部分串行了,其它的大部分都是并发的。 上述代码的所有步骤中,只有count++这一步是串行的,其它的操作如创建变量i,判定条件,调用add()和add()返回等操作全是并行的。



可见,加锁会导致阻塞。代码阻塞对程序的效率是有一定的影响的。此处加了锁,要比不加锁更慢一些,但肯定要不串行更快;同时也比不加锁算得更准。


(3)synchronized修饰方法


修饰普通成员方法


如果直接用synchronized修饰成员方法,这就相当于以this为锁对象:




修饰静态成员方法


如果synchronized修饰静态方法(static,即类方法),此时就不是给this加锁了,而是给类对象加锁。




补充:这里的类对象指的是 Counter.class。我们在.java源代码文件中编写的代码被javac编译为.class(二进制字节码文件)后,就可以被JVM执行了。而JVM要想执行这个.class文件,就得先把该文件的内容读取到内存中。这个将.class文件内容读取到内存中的操作叫做类加载。


在内存中,用类对象来表示.class文件内容。在.class文件中,描述了类的方方面面的详细信息,包括但不限于:1、类的名字;2、类有哪些属性,属性的名字、类型、访问权限;3、类有哪些方法,方法的名字,参数、类型、访问权限;4、类继承自哪个类;5、类实现了哪些接口……


此处的类对象,就相当于是“实例的图纸”。有了这个图纸,才能了解到这个实例是啥样的,进一步地才可以使用反射 API 来获取这里的一些信息。



(4)手动指定一个锁对象


更多时候,还是由我们自己来手动指定一个锁对象:



synchronized()括号中写什么都行,只要是一个Object实例。事实上,锁对象没有什么特别的,就是一个吉祥物,唯一的作用还是这句话:如果多个线程针对同一个对象加锁,就会产生锁竞争;多个线程针对不同的对象加锁,就不会有锁竞争。锁对象仅仅是起到一个标识的效果。


2、volatile 关键字:能保证内存可见性


(1)volatile 修饰的变量,能够保证其 “内存可见性”。


volatile的读音:https://fanyi.baidu.com/#en/zh/volatile


在上面引起线程不安全的原因中,提到了内存可见性问题:例如在下面的代码中,由于t1线程中while循环的转速极快,而将flag变量load进内存这一指令步骤耗费了主要开销,于是编译器就将load这一步骤优化掉了。这样一来,t1线程中的flag并不是每次循环都从内存中读取的,而是第一次从内存中读取到的值;且t2线程中对flag作出的更改在t1线程中感知不到。因此,该程序的运行结果并不会符合我们的预期。


// 预期:在t2线程中输入一个非0的数,t1线程中循环终止
 
public class Test2 {
    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 reader = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            flag = reader.nextInt();
        });
        t1.start();
        t2.start();
    }
}
 
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

而如果一个变量被 volatile 修饰,那么此时编译器就会禁止上述优化。换句话说,volatile 关键字能保证每次都从内存重新读取数据。

我们给flag加上volatile关键字修饰,再次运行上述代码。这时,代码的bug解决了,程序能够按照预期执行:



在加上volatile之前,由于t1线程中编译器优化掉了对flag的load这一指令,因此我们在t2线程中对flag作出的更改,在t1中感知不到。而加上了volatile之后,它能保证每一次while循环的条件判断都重新读取内存中flag的值,那么t2中对flag的修改在t1中能够立即感知到,这样一来t1的循环就能正确退出。  

给flag变量加上volatile,能恢复正常情况


直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存),速度非常快,但是可能出现数据不一致的情况。而加上 volatile , 强制读写内存, 速度是慢了,但是数据变的更准确了。这也印证了数据的准确性和程序效率往往不能兼得。


(2)volatile 不保证原子性

注意:volatile 和 synchronized 有着本质的区别。synchronized 能够保证原子性,而volatile 保证的是内存可见性,与原子性无关。


*至于 synchronized 是否也能保证内存可见性,是众说纷纭,存在争议的。

结论:

volatile适用于一个线程频繁读,一个线程写的情况。

synchronized适用于多个线程写的情况。


3、volatile关键字:禁止指令重排序


上面引起线程安全问题的原因中,提到了因为编译器优化造成指令重排序而导致的问题:



volatile关键字可以禁止指令重排序。如果用volatile关键字修饰s,那么创建对象时候就会禁止指令重排序,就能够保证执行顺序是 1->2->3 了。(PS:这个场景通过加锁也可以解决问题。)




四、补充:加锁的注意事项


加锁其实是一个比较低效的操作,因为加锁就可能涉及到阻塞等待。因此,实际情况中是“非必要,不加锁”。(如果不加锁程序就无法执行或执行结果错误,这就是不得不加锁的情况,此时必须加锁。)

相关文章
|
4天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
9天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
11 3
|
9天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
10 2
|
9天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
19 2
|
9天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
20 1
|
9天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
21 1
|
9天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
17 1
|
19天前
|
存储 安全 Java
Java-如何保证线程安全?
【10月更文挑战第10天】
|
25天前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
42 6
|
23天前
|
存储 运维 NoSQL
Redis为什么最开始被设计成单线程而不是多线程
总之,Redis采用单线程设计是基于对系统特性的深刻洞察和权衡的结果。这种设计不仅保持了Redis的高性能,还确保了其代码的简洁性、可维护性以及部署的便捷性,使之成为众多应用场景下的首选数据存储解决方案。
35 1