多线程笔记第二天(【线程安全】synchronized + volatile + wait + notify)

目录

1. 线程安全

1.1  出现线程不安全

 1.2 线程不安全的原因

1.3解决线程不安全(加锁🔒)

2.加锁使用synchronized🔒

2.1  修饰方法

2.2 修饰代码块

2.3 synchronized的特性

 2.4 锁竞争

3.volatile关键字(保证内存可见性)

3.1 volatile能保证内存可见性

3.2volatile不保证原子性   

3.3JMM(Java内存模型)

4.wait和notify(协调多个线程的执行顺序)

4.1 wait和notify方法

4.2 notifyAll方法

4.3 wait和sleep对比


 

1. 线程安全

1.1  出现线程不安全

两个线程,每个线程都针对counter进行5w次自增,预期结果是10w


class  Counter  {
    public int counter =0;
    public void increase() {
        counter++;
    }
}
public class Demo {
    private static Counter  counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread  t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("counter="+counter.counter);
    }
}

多次运行程序后,发现每次结果都不同,且不为10w

 

 

 进程counter++操作,底层是三条指令在CPU上完成的

(1)把内存中的数据读取到CPU寄存器中   load

(2)把CPU的寄存器的值进行+1操作          add

(3)把寄存器的值,写回到内存中              save

由于当前是两个线程修改一个变量,并且每次修改是三个步骤(不是原子操作),而且线程之间的调度顺序是不确定的,最终导致两个线程真正执行这些操作时,可能会有多种执行的排列顺序

 1.2 线程不安全的原因

(1)操作系统调度的随机性,抢占式执行(内核实现的 没办法避免)

多个线程的调度执行过程,可以视为是“全随机”的

(在写 多线程 代码时,需要考虑到,任意一种调度的情况下,都可以运行出正确结果的)

(2)多个线程修改一个变量

String是不可变对象(不能修改String对象的内容)

不可变对象,不是指final修饰,而是set系列方法隐藏了(private)

这样的好处就是其中一个是“线程安全”的。

(3)修改操作不是原子的(解决线程安全最常见的方法)

比如前面的counter++操作,本质就是三个CPU指令

load+add+save (CPU执行指令,都是以“一个指令”为单位进行执行,一个指令相当于CPU上“最小单位了”,不能说指令执行一半就把线程调度走)

但是像有些操作,比如int赋值,就是单个CPU指令,这个时候更加安全一些

(4)内存可见性

内存可见性属于是JVM的代码优化引入的bug

编译器优化:因为程序猿写代码的能力高低不同,所以想让编译器把写代码等价转化成另一种执行逻辑,使逻辑不变,效率提高

虽然这样的优化,能够使效率提高,非常优秀,但是多线程代码下容易出现误判

(5)指令重排序

1.3解决线程不安全(加锁🔒)

加锁:

就拿刚开始的例子来,在counter++之前加锁,在counter++后解锁,(这两个操作之间,就是独占线程的,别的线程用不了,独占就是 互斥)在加锁和解锁之间,可以进行修改,这个时候别的线程想要修改,是修改不了的(别的线程只能阻塞等待,阻塞等待的线程,BLOCKED状态


 

2.加锁使用synchronized🔒

synchronized的几种写法

(1)修饰普通方法,锁就相当于 this

(2)修饰代码块,锁对象在()指定

(3)修饰静态方法,锁对象相当于类对象(不是锁整个类)

2.1  修饰方法

 

使用synchronized关键字,来修饰一个普通方法

当进入方法的时候,就会加锁,方法执行完毕就会解锁 


class  Counter  {
    public int counter =0;
    public synchronized void increase()  {
        counter++;
    }
//    public void increase() {
//        counter++;
//    }
}
public class Demo {
    private static Counter  counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread  t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("counter="+counter.counter);
    }
}

🔒锁,具有独占的特性,如果当前锁没人加,加锁操作就能成功

如果当前锁已经被加上,加锁操作就会阻塞等待

 

这个操作相当于把“并发” 变成了“串行”,所以会减慢执行效率

加锁并不是说,CPU一次性执行完,之间也是有可能调度切换的

即使t1切走了,t2仍然是BLOCKED状态

increase里面涉及到 锁竞争  ,这里的代码时串行执行的,但是for循环在加锁的外面,两个for仍然是并发的,所以这个代码仍然要比两个循环串行执行要快,但是肯定比不加锁要慢 

效率  (完全串行 <  加锁 <  完全并发)

如果把for写到加锁的代码中,此时就和完全串行一样了

加锁需要考虑锁哪些代码,锁的范围不一样,代码执行效果会影响很大

加锁的代码越多,就说“锁的粒度越大/越粗”

加锁的代码越少,就说“锁的粒度越小/越细”

线程安全,不是加锁了就一定安全,而是通过锁,让并发修改同一个变量,变成串行修改同一个变量,才安全

 不正确的加锁操作,不一定能够解决线程安全问题

比如,一个线程加锁,一个线程不加锁,就不涉及到锁竞争,也就不会阻塞等待,也不会将并发修改变成串行修改

 

2.2 修饰代码块

可以把要进行加锁的逻辑放到 synchronized 修饰的代码块中,也能起到加锁的效果

在使用锁的时候,一定要明确,当前针对那个对象进行加锁,这就直接影响到了后面的操作是否会触发阻塞

()中要填的就是针对那个对象进行加锁(被用来加锁的对象,就叫“锁对象”)

任意对象都可以在 synchronized 里面作为锁对象,

所以我们写多线程代码时,不用关心这个锁对象是谁,是那种形态,

只要注意,两个线程是否锁同一个对象,如果锁同一个对象就会有“锁竞争” 

(1)针对当前对象加锁

 

谁调用increase2方法,谁就是this 

 (2)不用counter本事,而是用counter内部持有的另外一个对象

针对locker对象进行加锁,locker是Counter的一个普通成员,每个Counter实例中,都有自己的locker实例

(3)可以使用外部类的实例

 

2.3 synchronized的特性

(1) 互斥

synchronized里面的锁对象是this,这两个线程即使针对counter对象进行加锁,两个线程在执行过程中就会出现互斥的情况

 

(2)可重入

不会产生死锁,这样的锁叫“可重入锁
会产生死锁,这样的锁叫“不可重入锁
可重入锁底层实现,是比较简单的
只要让锁里面记录好,是哪个线程持有的这把锁
当第二次加锁时,锁一看发现还是那个加了锁的线程,就直接通过了,不会阻塞等待

 

可重入锁实现要点:
(1)让锁里持有线程对象,记录是谁加了锁
(2)维护一个计数器,用来判断什么时候是真加锁,什么时候是真解锁,什么时候直接放行

 

一个线程针对一把锁,连续加锁两次,
第一次加锁,能够加锁成功
第二次加锁,就会加锁失败(锁已经被占用)
导致在第二次加锁这里阻塞等待,等到第一把锁被解锁,第二把锁才能加锁成功
(第一把锁解锁,要求执行完synchronized代码块,也就是要求第二把锁加锁成功才可以)

 

 

 两次加锁,第一次加锁成功,第二次加锁看这个锁加锁了没,如果锁了就直接放行,但需要考虑的是直接放行后,要不要真解锁,如何来判断
方法是,引入一个计数器,每次加锁,计数器++,每次解锁计数器--,如果计数器为0,此时的加锁操作才能真加锁,同样计数器为0,此时的加锁操作才能真解锁

 2.4 锁竞争

锁竞争的核心是:无论锁对象,是什么状态,什么类型,只要两个线程争一个锁对象,就会产生锁竞争

锁竞争的目的:保证线程安全

下面看几种情况,理解一下锁竞争

(1)此时的locker是一个静态成员(类属性),类属性是唯一的(一个进程中,类对象只有一个,类属性也只有一个)
虽然counter和counter2是两个实例,但是这两个里面的locker实际是同一个locker
也就会产生锁竞争

 

 

 

 (2)第一个线程是针对locker对象进行加锁,第二个针对counter本身加锁
这两个线程针对不同对象加锁,不会产生锁竞争

 

 

 (3)类对象,在JVM进程中只有一个,如果多个线程来针对类对象加锁,就会锁竞争
所以下面这两个对象都是针对,同一个Counter.class加锁


3.volatile关键字(保证内存可见性)

3.1 volatile能保证内存可见性

什么叫做内存可见性

 

 但是前面的修改,对于t2的读内存操作不会有影响。
因为t2已经被优化成不再循环读内存了(读一次就完了)
t.把内存改了,t2没发现,这就是内存可见性问题,是由编译器优化出现的问题
(前面说过,编译器优化的前提是保证逻辑不变,让效率提高,但是在多线程情况下编译器就可能出现误判)
解决方案:为了解决编译器把不该优化的进行优化,就可以在代码中进行显示提醒编译器,这段代码不要进行优化,这也是volatile的作用

下面来看volatile的作用
volatile作用:可以使用这个关键字来修改一个变量
此时被修改的变量,编译器就强制不进行优化(不优化就可以,读取到内存了)

 

3.2volatile不保证原子性   

可以看到加了volatile的count,运行程序后,不是10w,说明加了volatile,针对两个线程这样的情况,只能保证内存可见性,不能保证“原子性”。

public class Demo01 {
    static class Counter {
        volatile public int count = 0;
 
        public void increase() {
            count++;
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        System.out.println(counter.count);
    }
}

 

 

3.3JMM(Java内存模型)

说到volatile,大概就要联系到JMM了
JMM = Java Memory Model(java内存模型)
更专业术语进行描述


 

 

4.wait和notify(协调多个线程的执行顺序)

4.1 wait和notify方法

wait notify 就是用来调配线程执行顺序的

 wait操作本质上三步走
(1)释放当前锁,保证其他线程能够正常往下进行(前提是得加了锁。才能释放)
(2)进行等待通知(前提是先要释放锁)
(3)满足一定条件的时候(别的线程调用notify),被唤醒,然后尝试重新获取锁

notify是包含在synchronized里面的
线程1没有释放锁的话,线程2也就无法调用到notify(因为锁阻塞等待)
线程1调用wait,在wait里面就释放了锁,这个时候虽然线程1代码阻塞在synchronized里面
但是此时锁还是释放状态,线程2能拿到锁

要确定加锁的对象,和调用wait的对象是同一个对象,并且也要确定调用wait的对象和调用notify的对象,也是同一个对象 
 下面看一下代码,理解wait和notify的执行顺序

public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        //准备一个对象,保证等待和通知是一个对象
        Object object = new Object();
 
        //第一个线程,进行 wait 操作
        Thread t1 = new Thread(() -> {
            while(true) {
                synchronized (object) {
                    System.out.println("wait 之前");
                    try {
                        object.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //这里写的代码,实在notify之后执行的
                    System.out.println("wait 之后");
                }
            }
        });
        t1.start();
 
        Thread.sleep(500);
        
        //第二个线程,进行notify
        Thread t2 = new Thread(() -> {
            while (true) {
                synchronized (object) {
                    System.out.println("notify 之前");
                    //这里写的代码,是在wait唤醒之前执行的
                    object.notify();
                    System.out.println("notify 之后");
                }
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t2.start();
    }
}

 

4.2 notifyAll方法

多个线程都在wait
notify是随机唤醒一个(用的更多)
notifyAll是全部唤醒(即使全部唤醒了所有wait,这些wait又需要重新竞争锁,重新竞争锁的过程仍然是串行的)

4.3 wait和sleep对比

理论上wait和sleep是完全没有可比性的,
唯一相同的是都可以让线程进入阻塞等待的
不同点:
(1)wait是Object类的成员本地方法,sleep是Thread类的静态本地方法
(2)wait必须在synchroized修饰的代码块或方法中和使用,而sleep方法可以在任何位置使用
(3)wait被调用后当前线程进入BLock状态并释放锁,需要通过notify或notifyAll进行唤醒也就是被动唤醒,sleep被调用后当前线程进入TIMED_WAIT状态,不涉及锁相关操作,能主动唤醒。
(4)sleep必须进行异常捕获,而wait,notify和notifyAll不需要异常捕获
sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常

 

  • 6
    点赞
  • 3
    收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:点我我会动 设计师:我叫白小胖 返回首页
评论 2

打赏作者

小孙的代码分享

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值