0


多线程的线程安全

多线程的线程安全

文章目录

线程安全是多线程的重点和难点,一定要好好理解

线程安全 : 在多线程各种随机的调度顺序下,代码都没有bug,都能符合 预期的方式执行

什么是bug : 不符合需求就算是bug

举一个实例来验证线程安全

classCounter{publicint count =0;publicvoidincrease(){
        count++;}}publicclass demo14 {publicstaticCounter counter =newCounter();publicstaticvoidmain(String[] args)throwsInterruptedException{Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
                counter.increase();}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
                counter.increase();}});
        t1.start();
        t2.start();//阻塞main,先执行 t1 t2线程,等他们执行完了,再执行main
        t1.join();
        t2.join();System.out.println("counter="+ counter.count);}}

预期的是,有两个线程各累加5000次,静态的count应该会变成10000,可是结果却不到10000次,并且count还是随机的

在进行count++的时候,底层会在CPU上执行3条指令(不是原子性)

  1. 把内存的数据读取到CPU寄存器上 load
  2. 把CPU的寄存器上的值+1 add
  3. 把寄存器中的值,写到内存中 save

串行: 机器执行完一条指命后,才取出下一条指令来执行的一种工作方式。

极端的两种情况:

如果两个线程之间的调度全是串行执行,结果就是10000

如果两个线程全是其他的情况,没有一次串行执行,结果就是5000

所以最终情况就是5000 - 10000

所以以上的随机值就是一种线程不安全

线程不安全的原因:

  1. 多线程之间抢占式执行(多线程不安全的根本原因)

任何一种调度都是有可能 的

  1. 多个线程修改同一个变量
  2. 执行修改的操作不是原子的(上述的count++就涉及到了3个CPU指令LOAD ADD SAVE)
  3. 内存可见性
  4. 指令重排序 (4 5 两点主要是JVM优化代码的时候出现的bug)

…(具体还得看代码实现)

要想解决线程安全问题,最常见的方法就是将多个操作通过特殊手段变成一个原子操作

在上面的例子中,可以在count++ 之前进行加锁,在count++之后进行解锁,在加锁与解锁之间进行修改count,此时别的进程修改不了count,别的线程出于阻塞状态(BLOCKED状态)

在java中,进行加锁,要使用synchronized 关键字

image-20220907141030597

加上锁之后就使别的线程变成了阻塞状态,由"并发"变成了 串行,运行效率确实降低,但是保证了多线程的安全

一定要知道: 加上锁不一定就能保证线程安全,正确的加锁是通过加锁,让并发修改同一个变量–>串行修改同一个变量

要是只给一个线程加锁,另一个不加锁,其实是没用的,只给一个线程加锁不会涉及到"锁竞争",也就不会有阻塞等待,也就不会并发执行–>串行执行(追根究底就是还是会抢占式执行)

synchronized锁对象的理解

关键字synchronized 不仅能修饰方法,还能修饰代码块

image-20220907144712315

synchronized 后面括号填的是锁对象, 也就是针对该对象进行加锁,谁要是调用increase方法,谁就是this–锁对象

锁对象不止可以是this,还可以是任何的对象

上面的synchronized锁方法其实默认的锁对象就是this

image-20220907145158918

写多线程代码的时候,最关心的是,两个线程锁的是否是同一个对象,只要锁同一个对象就会存在锁竞争

注意: 此处打印的是counter的count,所以要想达成10000,就要在counter上形成锁竞争,counter与counter2 里面的counter是不一样的

classCounter{publicint count =0;publicvoidincrease(){synchronized(this){
            count++;}}}publicclass demo14 {publicstaticCounter counter =newCounter();publicstaticCounter counter2 =newCounter();//再次设立一个新的对象publicstaticvoidmain(String[] args)throwsInterruptedException{Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
                counter.increase();}});Thread t2 =newThread(()->{for(int i =0; i <5000; i++){
                counter2.increase();}});
        t1.start();
        t2.start();//阻塞main,先执行 t1 t2线程,等他们执行完了,再执行main
        t1.join();
        t2.join();System.out.println("counter="+ counter.count);}}

其实这种写法与之前的synchronized后直接加this,counter调用的写法在线程安全角度是一样的

image-20220909095836820

注意: 此处打印的是counter的count,所以要想达成10000,就要在counter上形成锁竞争,counter与counter2 里面的counter是不一样的

总结就是一句话: 写多线程代码的时候,最关心的是,两个线程锁的是否是同一个对象,只要锁同一个对象就会存在锁竞争

死锁问题

对于一个线程,连续加锁两次,就会形成死锁

第二次加锁会阻塞等待,直到第一把锁解开,才能加第二把锁

第一把锁要想解开,要求第二把锁加锁成功

所以就这样子相互纠缠住了,就形成了死锁

image-20220909103051065

但是,死锁问题有时候是很难避免的,要是加锁函数1嵌套函数2 ,函数2嵌套函数3,函数3嵌套加锁函数4,这样子就会很难发现死锁问题

可重入锁

要是不会产生死锁的话,这样的所就叫做"可重入锁"

synchronized就是可重入的

可重入锁的底层实现是很简单的

只要让锁记录好时哪个线程持有的这把锁

加锁: t 线程尝试对this来加锁,锁就会记录是 t 线程持有了它

第二次锁就会发现,还是t线程,此时就会直接通过,不会再次加锁

解锁: 在锁里增加一个计数器,每次加锁就++,每次解锁就–,如果计数器为0,此时才真加锁,当计数器为0,此时才真解锁

总结:

可重入锁的实现要点:

  1. 让锁里持有线程对象,记录哪个线程加了锁
  2. 维护一个计数器,用来衡量什么时候真加锁,什么时候真解锁,什么时候直接通过

就算加锁代码出现异常,也还是会解锁,还是不会死锁—不得不说synchronized关键字是一个十分优秀的设计

所以上面的代码是不会引起死锁的

复习一下final :

final修饰一个变量: 禁止修改

final 修饰类: 禁止进程

final 修饰方法: 禁止重写

内存可见性

线程不安全的其中一个原因就是内存可见性

image-20220909170128127

要是程序需要频繁地读取数据,比较数据速度远快于LOAD的速度,此时编译器就会开始优化, 要是频繁地执行LOAD 并且 LOAD的结果还是一样的,编译器就会只执行一次LOAD,之后就不会重新读取内存了

importjava.util.Scanner;publicclassDemo16{publicstaticclassCounter{publicint count =0;}publicstaticvoidmain(String[] args){Counter  counter =newCounter();Thread t1 =newThread(()->{while(counter.count ==0){//具体操作}System.out.println("t1进程运行结束");});
        t1.start();Thread t2 =newThread(()->{System.out.println("请输入一个整数:");Scanner scanner =newScanner(System.in);
            counter.count = scanner.nextInt();//修改count});
        t2.start();}}

image-20220909171036729

运行以上的代码就会发现,while循环会一直执行,永远不会停止循环

原因: 内存中的count已经修改成了输入的1 ,但是刚才的修改并不会影响t1 的读内存操作,因为t1 的读内存已经被编译器优化成了不再循环读内存,只是读一次就好了,t1 还以为count还是0

也就是说, t2 把内存改了,但是t1没有没看见,这就是内存可见性问题

内存可见性是编译器优化惹的祸, 编译器在单线程情况下,对于代码的优化,逻辑是不会变的,但是编译器在多线程的情况下,很有可能会发生误判

要想解决内存可见性问题,就不要让编译器进行优化,由我们自己进行操作,此时就 要使用关键字volatile[ˈvɒlətaɪl]

volatile关键字

使用volatile"可变的"来修饰一个变量,这样子编译器就不会进行优化,也就是说,每次编译器都会去读取变量的值,

importjava.util.Scanner;publicclassDemo16{publicstaticclassCounter{volatilepublicint count =0;//加上volatile}publicstaticvoidmain(String[] args){Counter  counter =newCounter();Thread t1 =newThread(()->{while(counter.count ==0){}System.out.println("t1进程运行结束");});
        t1.start();Thread t2 =newThread(()->{System.out.println("请输入一个整数:");Scanner scanner =newScanner(System.in);
            counter.count = scanner.nextInt();});
        t2.start();}}

image-20220914200643504

此时编译器每次都会去读取内存中的数据

volatile能解决内存可见性问题,也就是一个线程读,一个线程修改的情况,它并不能保证原子性的问题

要是两个线程修改同一个变量还得是synchronized来保证原子性

publicclassDemo17{staticclassCounter{volatileint count =0;//只能解决内存可见性publicvoidincrease(){
             count ++;}}publicstaticvoidmain(String[] args)throwsInterruptedException{Counter counter =newCounter();Thread t1 =newThread(()->{for(int i =0; i <5000; i++){
                counter.increase();}});Thread t2 =newThread(()->{for(int i =0; i<5000; i++){
               counter.increase();}});
        t1.start();
        t2.start();
        t1.join();
        t2.join();System.out.println("count = "+ counter.count);}}//两个线程修改同一个变量,还是要 synchronized来保证原子性

要是谈到volatile就一定要知道JMM(Java Memory Model) Java内存模型

  1. 通俗地讲:

volatile禁止了编译器优化,避免了直接读取从CPU寄存器缓存的数据,而是每次都会重新读内存

Java语言为了更加通用,尽可能 避免硬件的差异,就起了一些术语

CPU寄存器–>工作内存(work memory) 内存—> 主内存(main m emory )

  1. 站在JMM角度看volatile:

正常程序执行的过程中,会把主内存的数据先加载到工作内存中,再进行计算处理,要是编译器进行优化,就不会每次都去主内存中读取,而是直接去工作内存读取, 这样就会导致内存可见性问题

volatile 起到的效果就是 ,保证每次读取内存都是真的才能够主内存中读取

注意: 工作内存不是真的内存,它只是CPU的寄存器,这是术语而已

wait 和 notify

多线程中总是会出现抢占式执行,可以使用wait 和 notify 来控制线程的执行顺序\

线程1调用了wait,线程1 就会阻塞,直到别的线程调用notify之后,线程1 才会继续执行

packageThreading;publicclassDemo18{publicstaticvoidmain(String[] args){Object object =newObject();System.out.println("wait之前");try{
            object.wait();}catch(InterruptedException e){
            e.printStackTrace();}System.out.println("wait之后");}}

这样写的结果,会报错

image-20220914223718232

不合法的锁状态异常

wait内部会进行一下三个操作:

  1. 释放当前的锁
  2. 进行等待
  3. 当有别的线程调用 notify 时,就会被唤醒,然后重新获取锁

所以要想要释放当前的锁的前提就是要先加上锁

packageThreading;publicclassDemo18{publicstaticvoidmain(String[] args){Object object =newObject();synchronized(object){//先加上锁,wait之后object就会解锁,其他的线程会获取到锁System.out.println("wait之前");try{
                object.wait();}catch(InterruptedException e){
                e.printStackTrace();}System.out.println("wait之后");}}}

要想使用wait 和 notify 就一定要先加上锁

举一个例子来理解wait与notify执行的具体顺序

packageThreading;publicclassDemo19{publicstaticvoidmain(String[] args){Object object =newObject();//创建 一个对象Thread t1 =newThread(()->{while(true){synchronized(object){System.out.println("wait之前");try{
                            object.wait();//前面加上锁}catch(InterruptedException e){
                            e.printStackTrace();}System.out.println("wait之后");}}});
            t1.start();Thread t2 =newThread(()->{while(true){synchronized(object){System.out.println("notify之前");
                        object.notify();//前面已经加上锁System.out.println("notify之后");}try{Thread.sleep(10000);}catch(InterruptedException e){
                        e.printStackTrace();}}});
            t2.start();}}

image-20220915144645447

就像是上面写的,t1先调用了wait, t2后调用notify, 此时notify就会唤醒wait

但是,要是t2先执行了 notify , t1 后执行wait , 或者干脆就不调用wait , 其实也没有什么事,只是不符合上面的规定罢了,什么都不会发生

notifyAll : 唤醒所有被wait的线程,

wait 与 notify 总结

wait notify是 用来控制多线程直接的执行先后顺序的

  1. wait 和 notify 都要先进行上锁(synchronized)
  2. 必须是同一个对象调用wait 和 notify
  3. 锁对象也要和 调用wait / notify 的对象一致
  4. 就算没有wait , 直接notify 也是没有副作用的

wait 与 sleep 的区别

首先要知道,wait 和 sleep 都是 让线程进入阻塞等待的状态

  1. 两个方法所属类不一样, sleep是thread 类的方法, wait是Object类的方法
  2. sleep是 通过时间来控制何时唤醒线程, wait 是 其他的线程通过notify 唤醒线程的(但是wait还有一个重载版本 , 参数可以传入时间, 表示等待的最大时间)
  3. 有无释放锁: 在调用wait之前, 必须要保证已经请求到锁, 调用之后会释放掉已经获得的锁,唤醒之会重新请求锁, sleep就不涉及到锁

练习

有三个线程,线程名称分别为:a,b,c。

每个线程打印自己的名称。

需要让他们同时启动,并按 c,b,a的顺序打印

publicstaticvoidmain(String[] args){Thread tc =newThread(()->{System.out.println("c");});Thread tb =newThread(()->{try{
            tc.join();//等待tc线程结束}catch(InterruptedException e){
            e.printStackTrace();}System.out.println("b");});Thread ta =newThread(()->{try{
            tb.join();//等待tb线程结束}catch(InterruptedException e){
            e.printStackTrace();}System.out.println("a");});
    ta.start();
    tb.start();
    tc.start();}

总结来说,就是线程c先开始, b等c结束再开始, a等b结束再开始

进阶版

有三个线程,分别只能打印A,B和C

要求按顺序打印ABC,打印10次

输出示例:

ABC

ABC

ABC

ABC

ABC

ABC

ABC

ABC

ABC

ABC

publicstaticboolean isThreadA =true;publicstaticboolean isThreadB =false;publicstaticboolean isThreadC =false;publicstaticvoidmain(String[] args){finalTest test =newTest();//其实创建一个Object对象也是一样的Thread t1 =newThread(()->{for(int i =0; i <10; i++){synchronized(test){while(!isThreadA){try{
                        test.wait();}catch(InterruptedException e){
                        e.printStackTrace();}}System.out.print("A");
                isThreadA =false;
                isThreadB =true;//交给线程2
                isThreadC =false;
                test.notifyAll();//唤醒}//以上的代码必须要synchronized里面,保证原子性}});Thread t2 =newThread(()->{for(int i =0; i <10; i++){synchronized(test){while(!isThreadB){try{
                        test.wait();}catch(InterruptedException e){
                        e.printStackTrace();}}System.out.print("B");
                isThreadA =false;
                isThreadB =false;
                isThreadC =true;
                test.notifyAll();}}});Thread t3 =newThread(()->{for(int i =0; i <10; i++){synchronized(test){while(!isThreadC){try{
                        test.wait();}catch(InterruptedException e){
                        e.printStackTrace();}}System.out.print("C");
                isThreadA =true;
                isThreadB =false;
                isThreadC =false;
                test.notifyAll();System.out.println();}}});
    t1.start();
    t2.start();
    t3.start();}

这道题就不好向上面一样使用join了,使用的是标识位 + 加锁 + wait notify 控制顺序

标签: 安全 jvm java

本文转载自: https://blog.csdn.net/m0_60354608/article/details/127344082
版权归原作者 fiance111 所有, 如有侵权,请联系我们删除。

“多线程的线程安全”的评论:

还没有评论