展开菜单
首页 精品内容 本月促销 装机必备 Windows PDF教程 macOS软件 IOS软件 Android AI 专题
全部分类

当前位置:

首页 > 编程开发 > Java多线程之线程安全问题怎么解决

Java多线程之线程安全问题怎么解决

1.线程安全概述1.1什么是线程安全问题首先我们需要明白操作系统中线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时线程的执行顺序是不确定的,有一些代码执行顺序不同不影响程序运行的结果,但也有一些代码执行顺序发生改变了重写的运行结果会受影响,这就造成程序会出现bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题。下面,将介绍一种典型的线程安全问题实例,整数自增问题。1.2一个存在线程安全问题的程序有一天,老师布置了这样一个问题:使用两个线程将变量count

本文目录

    Java多线程之线程安全问题怎么解决


    1.线程安全概述

    1.1什么是线程安全问题

    首先我们需要明白操作系统中线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时线程的执行顺序是不确定的,有一些代码执行顺序不同不影响程序运行的结果,但也有一些代码执行顺序发生改变了重写的运行结果会受影响,这就造成程序会出现bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题。

    下面,将介绍一种典型的线程安全问题实例,整数自增问题。

    1.2一个存在线程安全问题的程序

    有一天,老师布置了这样一个问题:使用两个线程将变量count自增10万次,每个线程承担5万次的自增任务,变量count的初始值为0。
    这个问题很简单,最终的结果我们也能够口算出来,答案就是10万。
    小明同学做事非常迅速,很快就写出了下面的一段代码:

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

    按理来说,结果应该是10万,我们来看看运行结果:
    Java多线程之线程安全问题怎么解决
    运行的结果比10万要小,你可以试着运行该程序你会发现每次运行的结果都不一样,但绝大部分情况,结果都会比预期的值要小,下面我们就来分析分析为什么会这样。

    2.线程加锁与线程不安全的原因

    2.1案例分析

    上面我们使用多线程运行了一个程序,将一个变量值为0的变量自增10万次,但是最终实际结果比我们预期结果要小,原因就是线程调度的顺序是随机的,造成线程间自增的指令集交叉,导致运行时出现两次自增但值只自增一次的情况,所以得到的结果会偏小。

    我们知道一次自增操作可以包含以下几条指令:

    1. 将内存中变量的值加载到寄存器,不妨将该操作记为load。

    2. 在寄存器中执行自增操作,不妨将该操作记为add。

    3. 将寄存器的值保存至内存中,不妨将该操作记为save。

    我们来画一条时间轴,来总结一下常见的几种情况:

    情况1: 线程间指令集,无交叉,运行结果与预期相同,图中寄存器A表示线程1所用的寄存器,寄存器B表示线程2所用的寄存器,后续情况同理。
    Java多线程之线程安全问题怎么解决
    情况2: 线程间指令集存在交叉,运行结果低于预期结果。
    Java多线程之线程安全问题怎么解决
    情况3: 线程间指令集完全交叉,实际结果低于预期。
    Java多线程之线程安全问题怎么解决
    根据上面我们所列举的情况,发现线程运行时没有交叉指令的时候运行结果是正常的,但是一旦有了交叉会导致自增操作的结果会少1,综上可以得到一个结论,那就是由于自增操作不是原子性的,多个线程并发执行时很可能会导致执行的指令交叉,导致线程安全问题。

    那如何解决上述线程不安全的问题呢?当然有,那就是对对象加锁。

    2.2线程加锁

    2.2.1什么是加锁

    为了解决由于“抢占式执行”所导致的线程安全问题,我们可以对操作的对象进行加锁,当一个线程拿到该对象的锁后,会将该对象锁起来,其他线程如果需要执行该对象的任务时,需要等待该线程运行完该对象的任务后才能执行。

    举个例子,假设要你去银行的ATM机存钱或者取款,每台ATM机一般都在一间单独的小房子里面,这个小房子有一扇门一把锁,你进去使用ATM机时,门会自动的锁上,这个时候如果有人要来取款,那它得等你使用完并出来它才能进去使用ATM,那么这里的“你”相当于线程,ATM相当于一个对象,小房子相当于一把锁,其他的人相当于其他的线程。
    Java多线程之线程安全问题怎么解决
    Java多线程之线程安全问题怎么解决
    在java中最常用的加锁操作就是使用synchronized关键字进行加锁。

    2.2.2如何加锁

    synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
    线程进入 synchronized 修饰的代码块, 相当于加锁,退出 synchronized 修饰的代码块, 相当于 解锁。

    java中的加锁操作可以使用synchronized关键字来实现,它的常见使用方式如下:

    方式1: 使用synchronized关键字修饰普通方法,这样会使方法所在的对象加上一把锁。
    例如,就以上面自增的程序为例,尝试使用synchronized关键字进行加锁,如下我对increase方法进行了加锁,实际上是对某个对象加锁,此锁的对象就是this,本质上加锁操作就是修改this对象头的标记位。

    class Counter {
        private int count;
        synchronized public void increase() {
            ++this.count;
        }
        public int getCount() {
            return this.count;
        }}

    多线程自增的main方法如下,后面会以相同的栗子介绍synchronized的其他用法,后面就不在列出这段代码了。

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

    看看运行结果:
    Java多线程之线程安全问题怎么解决
    方式2: 使用synchronized关键字对代码段进行加锁,但是需要显式指定加锁的对象。
    例如:

    class Counter {
        private int count;
        public void increase() {
            synchronized (this){
                ++this.count;
            }
        }
        public int getCount() {
            return this.count;
        }}

    运行结果:
    Java多线程之线程安全问题怎么解决
    方式3: 使用synchronized关键字修饰静态方法,相当于对当前类的类对象进行加锁。

    class Counter {
        private static int count;
        synchronized public static void increase() {
            ++count;
        }
        public int getCount() {
            return this.count;
        }}

    运行结果:
    Java多线程之线程安全问题怎么解决
    常见的用法差不多就是这些,对于线程加锁(线程拿锁),如果两个线程同时拿一个对象的锁,就会产生锁竞争,两个线程同时拿两个不同对象的锁不会产生锁竞争。
    对于synchronized这个关键字,它的英文意思是同步,但是同步在计算机中是存在多种意思的,比如在多线程中,这里同步的意思是“互斥”;而在IO或网络编程中同步指的是“异步”,与多线程没有半点的关系。

    synchronized 的工作过程:

    1. 获得互斥锁lock

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

    3. 执行代码

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

    5. 释放互斥锁unlock

    synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题,即死锁问题,关于死锁后续文章再做介绍。

    综上,synchronized关键字加锁有如下性质:互斥性,刷新内存性,可重入性。

    synchronized关键字也相当于一把监视器锁monitor lock,如果不加锁,直接使用wait方法(一种线程等待的方法,后面细说),会抛出非法监视器异常,引发这个异常的原因就是没有加锁。

    2.2.3再析案例

    对自增那个代码上锁后,我们再来分析一下为什么加上了所就线程安全了,先列代码:

    class Counter {
        private int count;
        synchronized public void increase() {
            ++this.count;
        }
        public int getCount() {
            return this.count;
        }}public class Main11 {
        private static final int CNT = 50000;
        private static final Counter counter = new Counter();
        public static void main(String[] args) throws InterruptedException {
    
            Thread thread1 = new Thread(() -> {
                for (int i = 0; i < CNT; i++) {
                    counter.increase();
                }
            });
            Thread thread2 = new Thread(() -> {
                for (int j = 0; j < CNT; j++) {
                    counter.increase();
                }
            });
    
            thread1.start();
            thread2.start();
    
            thread1.join();
            thread2.join();
    
            System.out.println(counter.getCount());
        }}

    多线程并发执行时,上一次就分析过没有指令集交叉就不会出现问题,因此这里我们只讨论指令交叉后,加锁操作是如何保证线程安全的,不妨记加锁为lock,解锁为unlock,两个线程运行过程如下:
    线程1首先拿到目标对象的锁,对对象进行加锁,处于lock状态,当线程2来执行自增操作时会发生阻塞,直到线程1的自增操作完毕,处于unlock状态,线程2才会就绪取执行线程2的自增操作。
    Java多线程之线程安全问题怎么解决
    加锁后线程就是串行执行,与单线程其实没有很大的区别,那多线程是不是没有用了呢?但是对方法加锁后,线程运行该方法才会加锁,运行完该方法就会自动解锁,况且大部分操作并发执行是不会造成线程安全的,只有少部分的修改操作才会有可能导致线程安全问题,因此整体上多线程运行效率还是比单线程高得多。

    2.3线程不安全的原因

    首先,线程不安全根源是线程间的调度充满随机性,导致原有的逻辑被改变,造成线程不安全,这个问题无法解决,无可奈何。

    多个线程针对同一资源进行写(修改)操作,并且针对资源的修改操作不是原子性的,可能会导致线程不安全问题,类似于数据库的事务。

    由于编译器的优化,内存可见性无法保证,就是当线程频繁地对同一个变量进行读操作时,会直接从寄存器上读值,不会从内存上读值,这样内存的值修改时,线程就感知不到该变量已经修改,会导致线程安全问题(这是编译器优化的结果,现代的编译器都有类似的优化不止于Java),因为相比于寄存器,从内容中读取数据的效率要小的多,所以编译器会尽可能地在逻辑不变的情况下对代码进行优化,单线程情况下是不会翻车的,但是多线程就不一定了,比如下面一段代码:

    import java.util.Scanner;public class Main12 {
        private static int isQuit;
        public static void main(String[] args) {
            Thread thread = new Thread(() -> {
                while (isQuit == 0) {
    
                }
                System.out.println("线程thread执行完毕!");
            });
            thread.start();
    
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入isQuit的值,不为0线程thread停止执行!");
            isQuit = sc.nextInt();
            System.out.println("main线程执行完毕!");
        }}

    运行结果:
    Java多线程之线程安全问题怎么解决
    我们从运行结果可以知道,输入isQuit后,线程thread没有停止,这就是编译器优化导致线程感知不到内存可见性,从而导致线程不安全。
    我们可以使用volatile关键字保证内存可见性。
    我们可以使用volatile关键字修饰isQuit来保证内存可见性。

    import java.util.Scanner;public class Main12 {
        volatile private static int isQuit;
        public static void main(String[] args) {
            Thread thread = new Thread(() -> {
                while (isQuit == 0) {
    
                }
                System.out.println("线程thread执行完毕!");
            });
            thread.start();
    
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入isQuit的值,不为0线程thread停止执行!");
            isQuit = sc.nextInt();
            System.out.println("main线程执行完毕!");
        }}

    运行结果:
    Java多线程之线程安全问题怎么解决

    synchronized与volatile关键字的区别:
    synchronized关键字能保证原子性,但是是否能够保证内存可见性要看情况(上面这个栗子是不行的),而volatile关键字只能保证内存可见性不能保证原子性。
    保证内存可见性就是禁止编译器做出如上的优化而已。

    import java.util.Scanner;public class Main12 {
        private static int isQuit;
        //锁对象
        private static final Object lock = new Object();
        public static void main(String[] args) {
            Thread thread = new Thread(() -> {
                    synchronized (lock) {
                        while (isQuit == 0) {
    
                        }
                        System.out.println("线程thread执行完毕!");
                    }
            });
            thread.start();
    
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入isQuit的值,不为0线程thread停止执行!");
            isQuit = sc.nextInt();
            System.out.println("main线程执行完毕!");
        }}

    运行结果:
    Java多线程之线程安全问题怎么解决

    编译器优化除了导致内存可见性感知不到的问题,还有指令重排序也会导致线程安全问题,指令重排序也是编译器优化之一,就是编译器会智能地(保证原有逻辑不变的情况下)调整代码执行顺序,从而提高程序运行的效率,单线程没问题,但是多线程可能会翻车,这个原因了解即可。

    3.线程安全的标准类

    Java 标准库中很多都是线程不安全的。这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施。例如,ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder。
    但是还有一些是线程安全的,使用了一些锁机制来控制,例如,Vector (不推荐使用),HashTable (不推荐使用),ConcurrentHashMap (推荐),StringBuffer。
    还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的,例如String。

    在线程安全问题中可能你还会遇到JMM模型,在这里补充一下,JMM其实就是把操作系统中的寄存器,缓存和内存重新封装了一下,其中在JMM中寄存器和缓存称为工作内存,内存称为主内存。
    其中缓存分为一级缓存L1,二级缓存L2和三级缓存L3,从L1到L3空间越来越大,最大也比内存空间小,最小也比寄存器空间大,访问速度越来越慢,最慢也比内存的访问速度快,最快也没有寄存器访问快。

    4.Object类提供的线程等待方法

    除了Thread类中的能够实现线程等待的方法,如join,sleep,在Object类中也提供了相关线程等待的方法。

    序号方法说明
    1public final void wait() throws InterruptedException释放锁并使线程进入WAITING状态
    2public final native void wait(long timeout) throws InterruptedException;相比于方法1,多了一个最长等待时间
    3public final void wait(long timeout, int nanos) throws InterruptedException相比于方法2,等待的最长时间精度更大
    4public final native void notify();唤醒一个WAITING状态的线程,并加锁,搭配wait方法使用
    5public final native void notifyAll();唤醒所有处于WAITING状态的线程,并加锁(很可能产生锁竞争),搭配wait方法使用

    上面介绍synchronized关键字的时候,如果不对线程加锁会产生非法监视异常,我们来验证一下:

    public class TestDemo12 {
        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("执行完毕!");
            });
    
            thread.start();
            System.out.println("wait前");
            thread.wait();
            System.out.println("wait后");
        }}

    看看运行结果:
    Java多线程之线程安全问题怎么解决
    果然抛出了一个IllegalMonitorStateException,因为wait方法的执行步骤为:先释放锁,再使线程等待,你现在都没有加锁,那如何释放锁呢?所以会抛出这个异常,但是执行notify是无害的。

    wait方法常常搭配notify方法搭配一起使用,前者能够释放锁,使线程等待,后者能获取锁,使线程继续执行,这套组合拳的流程图如下:
    Java多线程之线程安全问题怎么解决

    现在有两个任务由两个线程执行,假设线程2比线程1先执行,请写出一个多线程程序使任务1在任务2前面完成,其中线程1执行任务1,线程2执行任务2。
    这个需求可以使用wait/notify来实现。

    class Task{
        public void task(int i) {
            System.out.println("任务" + i + "完成!");
        }}public class WiteNotify {
        //锁对象
        private static final Object lock = new Object();
        public static void main(String[] args) throws InterruptedException {
            Thread thread1 = new Thread(() -> {
                synchronized (lock) {
                    Task task1 = new Task();
                    task1.task(1);
                    //通知线程2线程1的任务完成
                    System.out.println("notify前");
                    lock.notify();
                    System.out.println("notify后");
                }
            });
            Thread thread2 = new Thread(() -> {
                synchronized (lock) {
                    Task task2 = new Task();
                    //等待线程1的任务1执行完毕
                    System.out.println("wait前");
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    task2.task(2);
                    System.out.println("wait后");
                }
            });
            thread2.start();
            Thread.sleep(10);
            thread1.start();
        }}

    运行结果:
    Java多线程之线程安全问题怎么解决

    本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
    作者最新文章
    编程开发 Java
    相关文章 更多
    精品专题 更多
    装机必备

    正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

    Windows

    正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

    PDF教程

    正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

    macOS软件

    正软商城macOS软件专区,精选适用于Mac电脑的办公、设计、影音、效率、开发和系统工具,提供软件功能介绍、macOS兼容版本、正版授权及购买下载服务。

    IOS软件

    正软商城iOS软件专区,精选适用于iPhone和iPad的办公、学习、影音、设计、效率及AI应用,提供功能介绍、适用设备、系统要求和正版获取方式等信息。

    AI

    正软商城AI软件专区,汇集AI写作、AI绘画、AI视频、AI办公、AI编程、AI翻译、智能客服和数据分析等人工智能工具,提供功能介绍、适用平台、收费方式及正版购买信息。

    Mac软件 更多
    Aperty macOS版
    Aperty macOS版

    Aperty 是 Skylum 推出的 AI 人像修图软件,支持自然肤质修饰、瑕疵处理、面部与身体调整、预设及批量同步。

    火山引擎 网页版
    火山引擎 网页版

    火山引擎是字节跳动旗下企业级云与AI服务平台。聚焦大模型核心能力,依托字节自研豆包多模态大模型,凭借火山方舟一站式平台,提供模型精调、推理部署、智能应用搭建等全链路服务,结合高性能AI算力,助力各行业快速落地大模型数字化与智能化应用。

    立刻MV 网页版
    立刻MV 网页版

    立刻MV 面向音乐人、内容创作者和品牌团队,帮助用户低成本完成歌曲 MV、歌词视频、AI 分镜、图生视频与最终合成。

    蛙蛙写作 网页版
    蛙蛙写作 网页版

    蛙蛙写作 AI 创作工具,覆盖小说、剧本、文案,一键生成文稿,打通文字转视频全流程,高效解决创作难题。

    讯飞智作 网页版
    讯飞智作 网页版

    讯飞智作是一款基于语音合成技术、虚拟人驱动技术与数字人形象声音构建能力的 AI 数字人音视频创作平台。

    超级简历WonderCV 网页版
    超级简历WonderCV 网页版

    超级简历WonderCV是面向应届生和AI小白的智能求职平台,覆盖简历、面试与职业规划。

    音述AI 网页版
    音述AI 网页版

    音述AI,是一个以“用声音述说故事”为核心的 AI 音乐创作与声音分享社区。

    AionClaw macOS版
    AionClaw macOS版

    AionClaw 是可在桌面端运行的任务型 AI 助手,通过数字专家与技能市场组合办公、创作、数据和开发能力。

    Loomy macOS版
    Loomy macOS版

    Loomy 是科大讯飞推出的桌面 AI 助手,可通过对话、任务与工作目录协助处理办公、内容创作和资料整理。

    AirBuddy 3 macOS版
    AirBuddy 3 macOS版

    AirBuddy 3 是面向 macOS 的蓝牙外设增强工具,可集中查看 AirPods、Beats 与苹果设备电量,提供快速连接、播放控制、电量提醒、桌面小组件和多台 Mac 外设接力等功能。

    AirServer macOS版
    AirServer macOS版

    AirServer macOS版可把 Mac 转换为无线投屏接收端,通过 AirPlay 或 Google Cast 接收手机、平板和电脑画面,并支持多设备同时连接、高清镜像及会话录制,适合家庭娱乐、课堂教学、会议演示和应用展示。

    AnyDesk macOS版
    AnyDesk macOS版

    AnyDesk macOS版是一款适用于远程办公、设备维护和客户支持的远程桌面工具,可连接并操作异地计算机,支持文件传输、无人值守访问、会话权限控制及跨平台协作。

    WINDOWS 更多
    讯飞绘文 网页版
    讯飞绘文 网页版

    讯飞绘文网页版是科大讯飞推出的AI图文创作与内容运营工具,覆盖热点选题、文章生成、稿件校对、智能配图、排版发布和数据复盘等环节,适合自媒体创作者及内容团队在线使用。

    Maya(Autodesk Maya) Windows版
    Maya(Autodesk Maya) Windows版

    Autodesk Maya 是面向影视、电视、动画和游戏行业的专业三维制作软件,集多边形与 NURBS 建模、角色绑定、关键帧和动作捕捉动画、Bifrost 特效模拟、材质开发及 Arnold 渲染于一体,

    Aperty Windows版
    Aperty Windows版

    Aperty 是 Skylum 推出的 AI 人像修图软件,支持自然肤质修饰、瑕疵处理、面部与身体调整、预设及批量同步。

    火山引擎 网页版
    火山引擎 网页版

    火山引擎是字节跳动旗下企业级云与AI服务平台。聚焦大模型核心能力,依托字节自研豆包多模态大模型,凭借火山方舟一站式平台,提供模型精调、推理部署、智能应用搭建等全链路服务,结合高性能AI算力,助力各行业快速落地大模型数字化与智能化应用。

    立刻MV 网页版
    立刻MV 网页版

    立刻MV 面向音乐人、内容创作者和品牌团队,帮助用户低成本完成歌曲 MV、歌词视频、AI 分镜、图生视频与最终合成。

    蛙蛙写作 网页版
    蛙蛙写作 网页版

    蛙蛙写作 AI 创作工具,覆盖小说、剧本、文案,一键生成文稿,打通文字转视频全流程,高效解决创作难题。

    讯飞智作 网页版
    讯飞智作 网页版

    讯飞智作是一款基于语音合成技术、虚拟人驱动技术与数字人形象声音构建能力的 AI 数字人音视频创作平台。

    超级简历WonderCV 网页版
    超级简历WonderCV 网页版

    超级简历WonderCV是面向应届生和AI小白的智能求职平台,覆盖简历、面试与职业规划。

    音述AI 网页版
    音述AI 网页版

    音述AI,是一个以“用声音述说故事”为核心的 AI 音乐创作与声音分享社区。

    AionClaw Windows版
    AionClaw Windows版

    AionClaw 是可在桌面端运行的任务型 AI 助手,通过数字专家与技能市场组合办公、创作、数据和开发能力。

    Loomy Windows版
    Loomy Windows版

    Loomy 是科大讯飞推出的桌面 AI 助手,可通过对话、任务与工作目录协助处理办公、内容创作和资料整理。

    AirServer Windows版
    AirServer Windows版

    AirServer Windows版可把电脑变成AirPlay、Google Cast和Miracast无线投屏接收端,适合会议演示、课堂教学及家庭大屏分享。发送设备通常无需另装客户端,并可同时接收和智能排列多个镜像会话。