• 线程安全问题详解


    目录

    一、多线程的安全问题

    1、观察线程不安全

    2、线程安全的概念

    3、JMM 

     4、多线程的安全保证

    (1)原子性 

    (2)可见性 

    (3)防止指令重排 

    二、多线程安全问题的解决 

    1、多线程对象不同时

    2、synchronized关键字的使用

     3、synchronized关键字的说明

    4、synchronized关键字的特性 

     (1)互斥性

    (2)刷新内存        

    (3)可重入

    5、synchronized 使用示例

    (1)直接修饰普通方法:锁的是Counter对象

             (2)修饰静态方法: 锁的 Synchronize_static 类的对象

    (3)修饰代码块: 明确指定锁哪个对象

    6、java标准库中的线程安全类

     7、volatile关键字

    (1)volatile关键字可以保证共享变量的可见性

    (2)volatile关键字修饰的变量相当于一个内存屏障 

     三、相关代码


    一、多线程的安全问题

    1、观察线程不安全

    1. /**
    2. * 观察多线程的安全问题
    3. */
    4. public class ThreadSafeorUnsafe {
    5. public static class Counter{
    6. int count = 0;
    7. void increase() {
    8. count++;
    9. }
    10. }
    11. public static void main(String[] args) throws InterruptedException {
    12. Counter counter=new Counter();
    13. Thread t1=new Thread(()->{
    14. for (int i = 0; i <50000 ; i++) {
    15. counter.increase();
    16. }
    17. });
    18. Thread t2=new Thread(()->{
    19. for (int i = 0; i <50000 ; i++) {
    20. counter.increase();
    21. }
    22. });
    23. t1.start();
    24. t2.start();
    25. t1.join();
    26. t2.join();
    27. //主线程走到此处,t1和t2都已经执行结束,理性状态是count和等于1万
    28. System.out.println("t1和t2已经执行完毕~~");
    29. System.out.println(counter.count);
    30. }
    31. }

    此时的代码跑好多次都不是1万,并且每次都不一样

    2、线程安全的概念

    线程安全就是代码的串行和并行执行的结果是完全一致的,就说明当前线程是安全的

    多个线程串行执行的结果和并行执行的结果不同,这就是线程不安全

    第一次的是并行,会出现结果并不是理性的一万

    此时改为串行执行后,就是1万了

    所谓的线程安全问题就是在多线程并发的场景下,实际运行结果和单线程下的预期运行结果不符所出现的问题

    3、JMM 

    JMM--java的内存模型,描述多线程场景下java的线程内存(CPU的高速缓存和寄存器和主内存的关系),注意这个和JVM的呢村区域划分不是一个概念。

     每个线程都有自己的工作内存,每次读取变量(共享变量,不是线程的全局变量)都是先从主内存将变量加载到自己的工作内存中,之后关于此变量的所有操作都是在自己的工作内存中进行,然后写会主内存。

    共享变量:类中的成员变量,静态变量,常量都属于共享变量,也就是在堆和方法区中存储的变量

     4、多线程的安全保证

    一段代码要保证是线程安全的(多线程场景下和单线程场景下的运行结果保持一致),需要同时满足以下三个特性:

    (1)原子性 

    该操作对应CPU的一条指令,这个操作不会被中断,要么全部执行,要么就不执行,不会存在中间状态,这种操作就是一个原子性操作。

    例如:

    ①对于int a=10 这个操来说,就是直接将变量赋值给a变量,要么赋值成功,要么就没有赋值

    ②对于a+=10这个操作来说,先要读取当前变量的值,再将a+10计算,最后将计算得出的值重新赋值给a变量(对应了三个原子性操作)

    (2)可见性 

    一个线程对共享变量的修改,能够及时被其他线程看到,这种特性就是可见性

    两个小问题

    ①为啥需要整这么多内存?

    实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法. 所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.

    ② 为啥要这么麻烦的拷来拷去?

    因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也 就是几千倍, 上万倍).

    比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果 只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问 内存了. 效率就大大提高了. 那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥?? 答案就是:CPU很贵 。

    (3)防止指令重排 

    代码的书写顺序不一定就是最终JVM或者CPU的执行顺序

    一段代码是这样的:

    1. 去前台取下 U 盘

    2. 去教室写 10 分钟作业

    3. 去前台取下快递

    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问 题,可以少跑一次前台。这种叫做指令重排序

    对于单线程来说,指令重排是不会出现太大的问题的,但是在多线程的场景下,就有可能因为指令重排导致错误,一般就是对象还没初始化就被别的线程给用了

    二、多线程安全问题的解决 

    1、多线程对象不同时

    当多个线程使用了不同的线程对象时,多个线程之间就互不影响,因此就不会产生线程安全问题,是否会导致线程不安全,一定要注意,多个线程是否在操作同一个变量

    2、synchronized关键字的使用

    要确保一段代码的线程安全性,就需要同时满足原子性,可见性和防止指令重排现象。

    synchronized这个关键字就可以同时满足这三个条件。

     3、synchronized关键字的说明

    synchronized关键字——监视器锁(monitor lock 对象锁)

     "互斥"现象:当某个线程获取到该对象的锁时,其他线程也要获取同一个锁,就会处在阻塞状态

          当给increase方法加上synchronized关键字时,所有进入该对象的线程都需要获取当前counter对象的锁,获取成功后才可以进入,获取失败后就会进入阻塞状态。

           对于上述代码来说,当t1线程执行increase方法时,就会获取到counter对象的锁,然后执行increase方法,此时当y2线程并发执行increase方法时,由于当前counter对象的锁被t1持有,此时t2线程就会处于阻塞状态,直到t1释放这个锁

           当进入synchronized代码块时,就会尝试执行加锁操作,直到退出synchronized代码块之后,就会释放这个锁。正因为increase方法进行了“锁”处理,当多个线程在执行increase方法时其实是在排队进入,同一时刻只会有一个线程进入increase方法执行接下来的操作。

    "阻塞等待"的解释

    针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝 试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的 线程, 再来获取到这个锁. 注意: 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这 也就是操作系统线程调度的一部分工作. 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则

     若锁对象没有保存线程id,就说明该对象没有被任何线程持有

                                           若锁对象保存了线程id,当其他线程要获取锁时,就会处在阻塞状态

    4、synchronized关键字的特性 

     (1)互斥性

    1. public class ThreadSafeorUnsafe {
    2. public static class Counter{
    3. int count = 0;
    4. synchronized void increase() {
    5. //在方法前添加synchronized关键字之后,以下的代码操作和属性都是原子性的和可见性的
    6. System.out.println(Thread.currentThread().getName()+"获取到锁~~");
    7. count++;
    8. try {
    9. Thread.sleep(1000);
    10. } catch (InterruptedException e) {
    11. throw new RuntimeException(e);
    12. }
    13. }
    14. }
    15. public static void main(String[] args) throws InterruptedException {
    16. Counter counter=new Counter();
    17. Thread t1=new Thread(()->{
    18. for (int i = 0; i <50000 ; i++) {
    19. counter.increase();
    20. }
    21. },"t1");
    22. Thread t2=new Thread(()->{
    23. for (int i = 0; i <50000 ; i++) {
    24. counter.increase();
    25. }
    26. },"t2");
    27. Counter counter1=new Counter();
    28. Thread t3=new Thread(()->{
    29. for (int i = 0; i <50000 ; i++) {
    30. counter1.increase();
    31. }
    32. },"t3");
    33. t1.start();
    34. t2.start();
    35. t3.start();
    36. t1.join();
    37. t2.join();
    38. t3.join();
    39. //主线程走到此处,t1和t2都已经执行结束,理性状态是count和等于1万
    40. System.out.println("t1和t2已经执行完毕~~");
    41. System.out.println(counter.count);
    42. System.out.println(counter1.count);
    43. }
    44. }

    此时t1和t2构成互斥关系,t3和t1,t2不互斥,所以t3每次都可以获取到锁 

    (2)刷新内存        

    synchronized 的工作过程:

    1. 获得互斥锁

    2. 从主内存拷贝变量的最新副本到工作的内存

    3. 执行代码

    4. 将更改后的共享变量的值刷新到主内存

    5. 释放互斥锁

    刷新内存(天然保证原子性和可见性,同一时间只可能有一个线程进入同步块,当然是原子(所有操作结束才会释放锁),可见(操作完毕对于共享变量的值写会主内存才释放锁,其他线程获得锁时,主内存中的值一定是更改后的))

    (3)可重入

    java中的线程安全锁都是可重入的,可重入就是获得锁的线程可以再次加锁

    在上面的代码中, increase 和 increase1 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前 对象加锁的. 在调用 increase1的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁) 这个代码是完全没问题的. 因为 synchronized 是可重入锁. 

    java中的每个对象都有一个“对象头 ”(描述当前对象的锁信息-当前对象被那个线程持有,以及一个"计数器"-当前对象被锁的次数),在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息。synchronized的加锁和解锁是隐式的,是由JVM来进行操作的。当任意时刻该对象的计数器为0且持有线程为null,说明该对象可以被占有。

    ①若线程1进入increase1同步代码块时(synchronized),此时当前对象的对象头没有锁信息,线程1是第一个获取锁的线程,进入同步代码块,对象头就修改持有线程为线程1,并且计数器由0变到1,当线程1在increase1方法中调用increase()方法时,此时计数器就会由1变为2,说明当前对象被线程1锁了两次

    ②若线程2需要进入当前对象的increase1方法时,此时当前对象的对象头持有的是线程1,且计数器值不为0.此时线程2就会一直进入阻塞状态,直到线程1释放锁(也就是计数器的值为0时,就是真正的释放锁)。

    5、synchronized 使用示例

    (1)直接修饰普通方法:锁的是Counter对象

    synchronized修饰类中的成员方法,则锁的就是当前类的对象,当前这个方法是通过那个对象调用的,synchronized就锁的是那个对象。

     上图中t1和t2不互斥,各回各家,各所各门,假如t2也调用的是counter1对象,那么此时的t1和t2线程就会产生互斥,因为此时的t1和t2锁的是同一个对象。

    (2)修饰静态方法: 锁的 Synchronize_static 类的对象

    Synchronized修饰类中的静态方法,锁的是当前这个类的class对象(全局唯一,相当于把这个类锁了),同一时刻只能有一个线程访问这个方法(无论是几个对象)

    1. /**
    2. * synchronized修饰讲台方法
    3. */
    4. public class synchronize_static {
    5. public static void main(String[] args) {
    6. Counter counter1=new Counter();
    7. Counter counter2=new Counter();
    8. Counter counter3=new Counter();
    9. Thread t1=new Thread(()->{
    10. counter1.increase1();
    11. },"t1线程~~");
    12. Thread t2=new Thread(()->{
    13. counter2.increase1();
    14. },"t2线程~~");
    15. Thread t3=new Thread(()->{
    16. counter3.increase1();
    17. },"t3线程~~");
    18. t1.start();
    19. t2.start();
    20. t3.start();
    21. }
    22. private static class Counter {
    23. synchronized static void increase1() {
    24. while (true) {
    25. System.out.println(Thread.currentThread().getName() + "获取到锁~~");
    26. try {
    27. Thread.sleep(1000);
    28. } catch (InterruptedException e) {
    29. throw new RuntimeException(e);
    30. }
    31. }
    32. }
    33. }
    34. }

     此时的increase1是一个静态方法,锁的是counter.class对象(全局且唯一),无论通过那个对象调用increase1(),同一时刻只能有一个线程可以获取到锁,其他线程都在等待。也就是此时的t1,t2和t3都是互斥关系,就算三个线程调用的是不同的对象,都需要拿到Counter.class这个全剧唯一的对象。

    (3)修饰代码块: 明确指定锁哪个对象

    1. /**
    2. * synchronize修饰同步代码块
    3. */
    4. public class synchronize_codeblock {
    5. public static void main(String[] args) {
    6. Counter counter1=new Counter();
    7. Thread t1=new Thread(()->{
    8. counter1.increase();
    9. },"t1线程~~");
    10. Thread t2=new Thread(()->{
    11. counter1.increase();
    12. },"t2线程~~");
    13. Thread t3=new Thread(()->{
    14. counter1.increase();
    15. },"t3线程~~");
    16. t1.start();
    17. t2.start();
    18. t3.start();
    19. }
    20. private static class Counter{
    21. public void increase() {
    22. System.out.println("张三");
    23. System.out.println("李四");
    24. System.out.println("王麻子");
    25. //同步代码快,进入同步代码快,获取到指定的锁
    26. //this表示当前对象的引用,锁的就是当前对象
    27. synchronized (this) {
    28. while (true) {
    29. System.out.println(Thread.currentThread().getName() + "获取到锁~~");
    30. try {
    31. Thread.sleep(1000);
    32. } catch (InterruptedException e) {
    33. throw new RuntimeException(e);
    34. }
    35. }
    36. }
    37. }
    38. }
    39. }

    6、java标准库中的线程安全类

    对于类中的get方法上锁,只要多线程访问的是同一个vector对象,就都是互斥的 

    同理在hasntable中的添加和读取操作也是,所以Vector和hashtable不推荐使用

     对于CopyOnWriteArrayList来说,线程安全的List集合,采用的是读写分离的机制,多线程并发读,互斥写的操作,以及ConcurrentHashMap,StringBuffer来说,他们的锁时更加安全的。

     7、volatile关键字

    (1)volatile关键字可以保证共享变量的可见性

    相较于普通的共享变量,使用volatile关键字可以保证共享变量的可见性,对于同一个volatile变量,它的写操作一定发生在他的读操作之前,保证读到的数据一定是主内存刷新后的数据

    ①当线程读取的是volatile关键字的变量时,线程就会直接从主内存中读取到该值到工作内存中(无论当前工作内存中是否已经有该值)

    ②当线程写的是volatile关键字的变量时,将当前修改后的变量值(工作内存中的)立即刷新到主内存中,并且其他正在读此变量的线程会等待(不是阻塞),直到写会主内存操作完成,保证读到的一定是刷新后的主内存值。

    当没有volatile关键字时,t1将flag加载到工作内存后一只读取的是当前工作的内存值,t2的修改堆t1是不可见的,所以t2线程修改了flag的值后,t1线程是无法退出循环的

    1. public class NonVolatile {
    2. private static class Counter{
    3. int flag=0;
    4. }
    5. public static void main(String[] args) {
    6. Counter counter1=new Counter();
    7. Thread t1=new Thread(()->{
    8. //t1将flag加载到工作内存后一只读取的是当前工作的内存值,t2的修改堆t1是不可见的
    9. while (counter1.flag==0){
    10. //一直循环
    11. }
    12. System.out.println(counter1.flag+"退出循环~~");
    13. },"t1线程~~");
    14. t1.start();
    15. Thread t2=new Thread(()->{
    16. Scanner scanner=new Scanner(System.in);
    17. System.out.println("请修改flag的值:");
    18. counter1.flag=scanner.nextInt();
    19. },"t2线程~~");
    20. t2.start();
    21. }
    22. }

     当加上volatile关键字后,volatile变量每次都会读取主内存,此时t2线程的修改堆t1是可见的,当t2线程修改flag值不为0时,t1线程就会直接退出循环

    1. public class HaveVolatile {
    2. private static class Counter{
    3. volatile int flag=0;
    4. }
    5. public static void main(String[] args) {
    6. Counter counter1=new Counter();
    7. Thread t1=new Thread(()->{
    8. //volatile变量每次都会读取主内存
    9. while (counter1.flag==0){
    10. //一直循环
    11. }
    12. System.out.println(counter1.flag+"退出循环~~");
    13. },"t1线程~~");
    14. t1.start();
    15. Thread t2=new Thread(()->{
    16. Scanner scanner=new Scanner(System.in);
    17. System.out.println("请修改flag的值:");
    18. counter1.flag=scanner.nextInt();
    19. },"t2线程~~");
    20. t2.start();
    21. }
    22. }

     volatile关键字只能保证可见性,但是不能保证原子性,因此当代码不是原子性的操作时,依旧会出现线程不安全的问题。

    (2)volatile关键字修饰的变量相当于一个内存屏障 

     三、相关代码

    rocket_class_Grammer: java的语法相关知识的学习笔记 - Gitee.comhttps://gitee.com/ren-xiaoxiong/rocket_class_-grammer/tree/master/src/Thread/Safe

  • 相关阅读:
    SpringBoot连接TDengine集群写入超时
    玩转Vue3全家桶04丨升级:Vue2项目如何升级到Vue3?
    国际腾讯云:云服务器疑似被病毒入侵问题解决方案!!!
    Spring bean
    OkHttp 相关问题
    HC-SR04超声波模块测距原理与原理图
    vue.js javascript页面表单验证滚动
    Windows 系统 PostgreSQL 手工安装配置方法
    ARTS 打卡 第四周,游刃有余
    AI浪潮下,大模型如何在音视频领域运用与实践?
  • 原文地址:https://blog.csdn.net/m0_68989458/article/details/125407650