线程安全问题及其解决

文章目录

  • 一. 线程安全问题
    • 1.1 线程不安全的例子
    • 1.2 线程不安全的原因
      • 1.2.1 随即调度, 抢占式执行
      • 1.2.2 修改共享数据
      • 1.2.3 修改操作非原子性
      • 1.2.4 内存可见性
      • 1.2.5 指令重排序
      • 1.2.6 总结
  • 二. 线程安全问题的解决
    • 2.1 synchronized(解决前三个问题)
      • 2.1.1 synchronized 的锁是什么
      • 2.1.2 synchronized 的特性
    • 2.2 volatile关键字(解决第四,五个问题)
      • 2.2.1 volatile 能保证内存可见性
      • 2.2.2 volatile不保证原子性

一. 线程安全问题

当我们使用多个线程访问同一资源(可以是同一变量, 同一个文件, 同一条记录等) 的时候, 若多个过程只有读操作, 那么不会发生线程安全问题. 但是如果多个线程中对资源有读和写的操作, 就容易出现线程安全问题.

1.1 线程不安全的例子

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

我们认为count应该是100000, 但是实际输出的却不是, 这就说明上述代码有线程安全问题.

1.2 线程不安全的原因

1.2.1 随即调度, 抢占式执行

线程的调度不是按顺序的, 而是抢占式的, 这是系统规定, 我们无法修改.

1.2.2 修改共享数据

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

在这里插入图片描述

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

1.2.3 修改操作非原子性

什么是原子性?

我们把一段代码想象成一个房间, 每个线程就是要进入这个房间的人. 如果没有任何机制保证, A进入房间之后, 还没有出来; B 是不是也可以进入房间, 打断 A 在房间里的隐私. 这个就是不具备原子性的.

那我们应该如何解决这个问题呢? 是不是只要给房间加一把锁, A 进去就把门锁上, 其他人是不是就进不来了. 这样就保证了这段代码的原子性了.

有时也把这个现象叫做同步互斥, 表示操作是互相排斥的.

一条Java语句不一定是原子的, 也不一定只是一条指令.

比如上述代码中的count+=1其实是三个操作组成的:

  • 从内存把数据读到CPU
  • 进行数据更新
  • 把数据写回到CPU
时间t1t2
T1load
T2load
T3add
T4save
T5add
T6save

在这里插入图片描述

不保证原子性会带来的问题: 如果一个线程正在对一个变量操作, 中途其他线程插入进来了, 如果这个操作被打断了, 结果就可能是错误的.

这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.

1.2.4 内存可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

我们先来看下面的代码

public class Demo {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                ;
            }
            System.out.println("t1 end");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("其输入isQuit的值");
            isQuit = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们先输入1, 结果线程t1还未停止.

在这里插入图片描述

在这里插入图片描述

我们想让线程t1在isQuit非零时停止, 但事实并非所愿, 这就是内存可见性引发的线程安全问题.

程序在编译运行的时候, Java编译器和JVM可能会对代码作出一些"优化", 在保持原有逻辑不变的情况下, 提高代码的执行效率, 这就称为 编译器优化.

编译器优化本质是靠代码智能地对代码进行分析判断, 进行调整. 这个调整过程大部分情况下都能保持逻辑不变, 但是如果遇到多线程, 就可能会发生差错, 逻辑改变.

while (isQuit == 0)

本质上是两个指令: 一是读内存; 二是比较并跳转

比较操作是在寄存器上进行的, 速度十分快, 相较之下, 读内存操作就会显得很慢.

此时, JVM就会反应到, 这个代码要反复读取同一个内存值, 读出的结果还都是一样的, 于是编译器就直接把读内存这个指令给优化掉了, 只读一次内存, 后续直接拿寄存器中的数据比较, 大大加快了执行速度.

但是, JVM没有预料到我们会在其他线程修改isQuit的值, 编译器没法准确判定出t2线程会不会执行, 什么时候执行, 因此就出现了误判.

虽然其他线程把值修改了, 但是另一个线程中没有重复读取isQuit的值, 这就引发了内存可见性的问题.

1.2.5 指令重排序

指令重排序也是编译器优化的一种手段, 在保证原有逻辑不发生变化的情况下, 对代码执行的顺序进行调整, 使调整后的执行效率变高.

编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

1.2.6 总结

线程安全问题的原因:

  • 根本原因: 多个线程之间的调度顺序是"随机的", 操作系统使用"抢占式"执行的策略来调度线程.
    • 当前主流的操作系统都是抢占式执行的.
  • 多个线程同时修改同一个变量.
  • 此处进行的修改不是"原子的".
  • 内存可见性引起的线程安全问题.
  • 指令重排序引起的线程安全问题.

二. 线程安全问题的解决

2.1 synchronized(解决前三个问题)

为了保证每个线程都能正常执行原子操作, Java引入了线程同步机制. 注意: 在任何时候, 最多允许一个线程拥有同步锁, 谁拿到锁就进入代码块, 其他的线程只能在外等着( BLOCKED) .

同步机制的原理, 其实就相当于给某段代码加“锁”, 任何线程想要执行这段代码, 都要先获得“锁”, 我们称它为同步锁.

2.1.1 synchronized 的锁是什么

同步代码块: synchronized 关键字可以用于某个区块前面, 表示只对这个区块的资源实行互斥访问.

synchronized(加锁的对象){
     //需要同步操作的代码
}

public void increase() {
    synchronized(this) {
        count+=1;
    }
}

同步方法: synchronized 关键字直接修饰方法, 表示同一时刻只有一个线程能进入这个方法, 其他线程在外面等着.

public synchronized void method(){
    //可能会产生线程安全问题的代码
}

public synchronized void increase() {
    count+=1;
}

synchronized 进行加锁解锁, 是以对象为维度进行的. 使用synchronized 的时候, 其实是指定了某个具体对象进行加锁.

对于同步代码块来说, 同步锁对象是由程序员手动指定的 ; 但是对于同步方法来说, 同步锁对象只能是默认的:

  • 静态方法: 默认加锁对象是当前类的Class对象(类名.class)

    public synchronized static void method(){
        //可能会产生线程安全问题的代码
    }
    //相当于
    public static void method(){
        synchronized(类名.class) {
            //可能会产生线程安全问题的代码
        }
    }
    
  • 非静态方法: 默认加锁对象是this

如果多个线程对同一个对象进行加锁, 就会出现锁竞争; 如果是多个对象针对不同的对象进行加锁, 不会产生锁竞争.

class Counter {
    public int count = 0;
    public void increase() {
        synchronized(this) {//对this加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        // 两个线程对同一个对象(this -> counter)加锁, 那么结果就是100000
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}
class Counter {
    public int count = 0;
    private Object locker = new Object();
    public void increase() {
        synchronized(this) {//对this加锁
            count+=1;
        }
    }
    public void increase2() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        // 两个线程对不同对象(this -> counter / locker)加锁, 那么结果就不是100000.
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}
class Counter {
    public int count = 0;
    private Object locker = new Object();
    public void increase() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
    public void increase2() {
        synchronized(locker) {//对locker加锁
            count+=1;
        }
    }
}
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        Thread thread2 = new Thread(()-> {
            for (int i = 0; i < 50000; i++) {
                counter.increase2();
            }
        });
        // 两个线程对同一对象(locker)加锁, 那么结果就是100000.
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(counter.count);
    }
}

总结:

同步锁对象可以是任意类型, 但是必须保证 竞争"同一个共享资源"的多个线程必须针对同一个对象进行加锁。

2.1.2 synchronized 的特性

  1. 互斥

    synchronized 会起到互斥效果, 某个线程执行到 synchronized 修饰的代码块中时, 其他线程如果也执行到同一个对象的此代码块, 就会阻塞等待.

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

    可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).

    如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.

    如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队

    理解"阻塞等待"

    针对每一把锁, 操作系统内部都维护了一个等待队列, 当这个索贝某个线程栈有的时候, 其他线程尝试进行加锁, 就加不上了, 会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.

    注意:

    • 上一个线程解锁之后, 下一个线程并不会立即就能获得到锁, 而是要靠操作系统来"唤醒", 这也是操作系统调度的一部分工作.
    • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
  2. 刷新内存

    synchronized 的工作过程:

    • 获得互斥锁
    • 从主内存拷贝变量的最新副本到工作的内存
    • 执行代码
    • 将更改后的共享变量的值刷新到主内存
    • 释放互斥锁

2.2 volatile关键字(解决第四,五个问题)

2.2.1 volatile 能保证内存可见性

volatile 修饰的变量, 编译器就不会把读操作优化到都寄存器中, 于是就能保证在循环过程中, 始终能读取内存中的数据, 保证 “内存可见性”.

在这里插入图片描述

代码在 写入 volatile修饰的变量的时候:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存
    • 工作内存(work memory) : 是指存储区, 包括cpu寄存器和cpu缓存.

代码在 读取 volatile 修饰的变量的时候:

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不一致的情况.

加上volatile, 强制读写内存, 速度虽然慢了, 但是数据更准确了.

代码示例

在这个代码中

  • 创建两个线程 t1 和 t2

  • t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.

  • t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.

  • 预期当用户输入非 0 的值的时候, t1 线程结束.

static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (counter.flag == 0) {
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

t1读的是自己工作内存中的数据

当t2对flag变量进行修改, 此时t1感知不到flag的变化

如果给 flag 加上 volatile

static class Counter {
    public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.

2.2.2 volatile不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

代码示例

static class Counter {
    volatile public int count = 0;
    void increase() {
        count++;
   }
}
public static void main(String[] args) throws InterruptedException {
    final 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);
}

此时可以看到, 最终 count 的值仍然无法保证是 100000.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/149600.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

基于PyTorch搭建你的生成对抗性网络

前言 你听说过GANs吗&#xff1f;还是你才刚刚开始学&#xff1f;GANs是2014年由蒙特利尔大学的学生 Ian Goodfellow 博士首次提出的。GANs最常见的例子是生成图像。有一个网站包含了不存在的人的面孔&#xff0c;便是一个常见的GANs应用示例。也是我们将要在本文中进行分享的…

Apache Pulsar 技术系列 - 基于 Pulsar 的海量 DB 数据采集和分拣

导语 Apache Pulsar 是一个多租户、高性能的服务间消息传输解决方案&#xff0c;支持多租户、低延时、读写分离、跨地域复制、快速扩容、灵活容错等特性。本文是 Pulsar 技术系列中的一篇&#xff0c;主要介绍 Pulsar 在海量DB Binlog 增量数据采集、分拣场景下的应用。 前言…

阿里影业S1财报解读:优质内容叠加整合效益,转动增长飞轮

从《消失的她》到《长安三万里》再到《孤注一掷》&#xff0c;市场对阿里影业半年报好成绩已有所预期。 11月13日&#xff0c;阿里影业发布2023/24半年度业绩。根据财报&#xff0c;报告期内&#xff08;4月1日至9月30日&#xff09;&#xff0c;阿里影业实现收入人民币26.16亿…

深入理解SqueezeSegV3点云分割

文章&#xff1a;Squeezesegv3: Spatially-adaptive convolution for efficient point-cloud segmentation 代码&#xff1a;https://github.com/chenfengxu714/SqueezeSegV3 一、摘要 激光雷达点云分割是许多应用中的一个重要问题。对于大规模点云分割&#xff0c;一般是投…

【算法每日一练]-图论(保姆级教程 篇2(topo排序,并查集,逆元))#topo排序 #最大食物链 #游走 #村村通

今天讲topo排序 目录 题目&#xff1a;topo排序 思路&#xff1a; 题目&#xff1a;最大食物链 解法一&#xff1a; 解法二&#xff1a; 记忆化 题目&#xff1a;村村通 思路&#xff1a; 前言&#xff1a;topo排序专门处理DAG&#xff08;有向无环图&#xff09; 题目…

15个顶级元宇宙游戏

元宇宙游戏是可让数百万玩家在一个虚拟世界中相互互动&#xff0c;允许你按照自己的节奏玩游戏&#xff0c;并根据自己的条件推广自己的品牌。 而且&#xff0c;这些游戏中的大多数都涉及虚拟 NFT&#xff0c;它们是完全独特的和虚拟的。在 Facebook 将品牌重新命名为“Meta”…

Spring 国际化:i18n 如何使用

1、i18n概述 国际化也称作i18n&#xff0c;其来源是英文单词 internationalization的首末字符i和n&#xff0c;18为中间的字符数。由于软件发行可能面向多个国家&#xff0c;对于不同国家的用户&#xff0c;软件显示不同语言的过程就是国际化。通常来讲&#xff0c;软件中的国…

11月第2周榜单丨飞瓜数据B站UP主排行榜榜单(B站平台)发布!

飞瓜轻数发布2023年11月6日-11月12日飞瓜数据UP主排行榜&#xff08;B站平台&#xff09;&#xff0c;通过充电数、涨粉数、成长指数、带货数据等维度来体现UP主账号成长的情况&#xff0c;为用户提供B站号综合价值的数据参考&#xff0c;根据UP主成长情况用户能够快速找到运营…

【JUC】六、辅助类

文章目录 1、CountDownLatch减少计数2、CyclicBarrier循环栅栏3、Semaphore信号灯 本篇整理JUC的几个同步辅助类&#xff1a; 减少计数&#xff1a;CountDownLatch循环栅栏&#xff1a;CyclicBarrier信号灯&#xff1a;Semaphore 1、CountDownLatch减少计数 案例&#xff1a;6…

基于opencv+tensorflow+神经网络的智能银行卡卡号识别系统——深度学习算法应用(含python、模型源码)+数据集(二)

目录 前言总体设计系统整体结构图系统流程图 运行环境模块实现1. 训练集图片处理1&#xff09;数据加载2&#xff09;图像处理 2. 测试图片处理1&#xff09;图像读取2&#xff09;图像处理 相关其它博客工程源代码下载其它资料下载 前言 本项目基于从网络获取的多种银行卡数据…

政府指导89元保330万 “聊惠保”2024年度正式上线!

11月15日&#xff0c;“聊惠保”2024年度启动仪式在聊城市融媒体中心举行。市政府领导&#xff0c;省直、市直相关部门单位和共保体成员单位负责同志参加仪式。“聊惠保”2024年度正式上线&#xff01;“聊惠保”项目组为聊城市医疗救助困难群体捐赠“聊惠保”2024年度团体保险…

python基础练习题库实验八

文章目录 前言题目1代码 题目2代码 题目3代码 总结 前言 &#x1f388;关于python小题库的这模块我已经两年半左右没有更新了&#xff0c;主要是在实习跟考研&#xff0c;目前已经上岸武汉某211计算机&#xff0c;目前重新学习这门课程&#xff0c;也做了一些新的题目 &#x…

LeetCode34-34. 在排序数组中查找元素的第一个和最后一个位置

&#x1f517;:代码随想录:二分查找的算法讲解:有关left<right和left<right的区别 class Solution {public int[] searchRange(int[] nums, int target) {int nnums.length;int l0,hn-1;if(numsnull){return null; }if(n0){return new int[]{-1,-1}; }if(target&l…

阿里云99元ECS云服务器老用户也能买,续费同价!

阿里云近日宣布了2023年的服务器优惠活动&#xff0c;令用户们振奋不已。最引人瞩目的消息是&#xff0c;阿里云放开了老用户的购买资格&#xff0c;99元服务器也可以供老用户购买&#xff0c;并且享受续费的99元优惠。此外&#xff0c;阿里云还推出了ECS经济型e实例&#xff0…

8年经验的软件工程师建议

我希望在职业生涯早期就开始做的事情和我希望以不同的方式做的事情。 大家好&#xff0c;我已经做了八年半的软件工程师。这篇文章来源于我最近对自己在职业生涯中希望早点开始做的事情以及希望以不同方式做的事情的自我反思。 我在这里分享的对任何希望提高和进步到高级甚至…

Java远程操作Linux服务器命令

Java可以通过SSH协议远程连接Linux服务器&#xff0c;然后使用JSch库或者Apache Commons Net库来执行远程Linux命令。以下是一个使用JSch库的示例代码&#xff1a; import com.jcraft.jsch.*;public class RemoteCommandExecutor {private String host;private String user;pr…

2023年亚太杯数学建模思路 - 复盘:人力资源安排的最优化模型

文章目录 0 赛题思路1 描述2 问题概括3 建模过程3.1 边界说明3.2 符号约定3.3 分析3.4 模型建立3.5 模型求解 4 模型评价与推广5 实现代码 建模资料 0 赛题思路 &#xff08;赛题出来以后第一时间在CSDN分享&#xff09; https://blog.csdn.net/dc_sinor?typeblog 1 描述 …

ubuntu 20通过docker安装onlyoffice,并配置https访问

目录 一、安装docker &#xff08;一&#xff09;更新包列表和安装依赖项 &#xff08;二&#xff09;添加Docker的官方GPG密钥 &#xff08;三&#xff09;添加Docker存储库 &#xff08;四&#xff09;安装Docker &#xff08;五&#xff09;启动Docker服务并设置它随系…

2024年上半年:加密领域迎来无限机遇与重大突破!

2024年上半年将成为加密行业发展的关键时期&#xff0c;一系列重大事件和计划将为这一领域带来深远的影响。这些举措不仅有望吸引更多机构投资者和资金流入加密市场&#xff0c;还将进一步提升比特币的认可度和流动性&#xff0c;推动整个行业迈向新的阶段。 SEC批准比特币现货…