当前位置:

首页 > 编程开发 > 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
    相关文章 更多
    Java单元测试入门:使用JUnit 5编写第一个测试用例
    Java单元测试入门:使用JUnit 5编写第一个测试用例

    面向初学者的JUnit 5实用入门指南。从测试概念、Maven依赖配置,到手把手编写首个单元测试用例及断言验证,助你掌握Java单元测试的核心流程。

    Java变量未初始化报错解析:局部变量与成员变量默认值差异
    Java变量未初始化报错解析:局部变量与成员变量默认值差异

    深入解析Java中局部变量未初始化编译报错与成员变量自动赋予默认值的机制差异,涵盖JVM内存分配、编译期确定性赋值检查与实际排查修复方案。

    Java获取当前时间并格式化输出:DateTimeFormatter与SimpleDateFormat
    Java获取当前时间并格式化输出:DateTimeFormatter与SimpleDateFormat

    详细讲解在Java中获取当前系统时间并进行格式化输出的两种核心方案:基于Java 8全新日期时间API的DateTimeFormatter与传统遗留的SimpleDateFormat,包含完整代码示例、线程安全机制对比及生产选型建议。

    Java中final与static final常量的定义及初始化规则
    Java中final与static final常量的定义及初始化规则

    深入解析Java中final变量与static final常量的定义、内存语义、初始化时机及合法赋值路径,涵盖编译期常量优化与实战避坑指南。

    Java抽象方法与接口方法的区别及选型建议
    Java抽象方法与接口方法的区别及选型建议

    深入对比Java中抽象类抽象方法与接口方法的语法演进、设计意图及运行期差异,并提供清晰的架构选型决策流程与实战范例。

    Java实现HTTP请求与JSON解析的完整代码示例
    Java实现HTTP请求与JSON解析的完整代码示例

    本文提供Java标准库HttpClient发起HTTP GET/POST请求并结合Jackson进行JSON数据反序列化与序列化的完整代码示例与工程实践指南。

    Java递归出现StackOverflowError的原因分析及栈空间优化方案
    Java递归出现StackOverflowError的原因分析及栈空间优化方案

    详细分析Java递归调用出现StackOverflowError的原因,结合代码说明线程栈、栈帧和递归深度之间的关系,并介绍终止条件排查、迭代改写、减少栈帧开销以及通过-Xss调整JVM栈空间的方法。

    Java空指针异常(NPE)的代码排查与修复实例
    Java空指针异常(NPE)的代码排查与修复实例

    针对Java中常见的NullPointerException(NPE),本文从堆栈日志定位、典型代码触发场景、具体修复代码对比到防御体系进行系统讲解,提供实用且易上手的排查与修复方案。

    selenium上传文件教程本地文件路径设置方法
    selenium上传文件教程本地文件路径设置方法

    深入解析Selenium处理文件上传的技术细节,涵盖标准HTML控件的路径直接注入法与非标准控件的系统级模拟方案,提供Windows与Linux环境下的路径设置最佳实践及常见异常排查。

    大数据开发语言选型:Java对比Scala与Python的优缺点及适用场景
    大数据开发语言选型:Java对比Scala与Python的优缺点及适用场景

    针对大数据工程场景,系统对比Java、Scala与Python在执行性能、生态结合、开发效率和维护成本上的核心差异,给出清晰的架构选型依据与落地方案。

    查看更多
    精品专题 更多
    装机必备
    装机必备

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

    Windows
    Windows

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

    PDF教程
    PDF教程

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

    Mac软件 更多
    讯飞智作 网页版
    讯飞智作 网页版
    Windows/ macOS/ Linux

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

    火山引擎 网页版
    火山引擎 网页版
    Windows/ macOS/ Linux

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

    Aperty macOS版
    Aperty macOS版
    macOS

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

    WINDOWS 更多
    讯飞智作 网页版
    讯飞智作 网页版
    Windows/ macOS/ Linux

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

    火山引擎 网页版
    火山引擎 网页版
    Windows/ macOS/ Linux

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

    PixTV 网页版
    PixTV 网页版
    Windows

    PixTV 是一款在浏览器中使用的 AI 视觉内容创作平台,提供图片、视频、音频、文字生成、AI 分镜、局部重绘、提示词反推和无限画布等工具,适合短剧、漫剧、广告及社交媒体内容制作。