当前位置: 首页 > article >正文

【Java 并发编程】多线程安全问题(上)

前言


        虽然并发编程让我们的 CPU 核心能够得到充分的使用,程序运行效率更高效。但是也会引发一些问题。比如当进程中有多个并发线程进入一个重要数据的代码块时,在修改数据的过程中,很有可能引发线程安全问题,从而造成数据异常

public class Demo3 {
    static int Count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                Count++;
            }
        });
        Thread t2 = new Thread(()->{
            for(int i = 0;i<50000;i++){
                Count++;
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Count);
    }
}

        以上输出结果是一个小于 100000 的随机值,输出结果不符合预期说明代码有 bug ~

        补充解释:此处代码中的 count++ 操作,其实在 CPU 视角来看,是 3 个指令

(1)把内存中的数据,读取到 cpu 寄存器里
(2)把 CPU 寄存器里的数据 +1
(3)把寄存器的值,写回内存

        然而,我们的程序是并发进行的,可能正在执行 线程t1 的某些指令,就立刻切换到执行 线程t2 的某些指令。如下图所示:当 t1 执行到第二个指令时,由于程序是并发执行的,在这个时候立刻切换到 t2线程 的指令1。注意,此时 t1线程虽然寄存器存着修改后的值,但是内存并没有修改,所以我们 t2线程 去获取内存中的值仍然是0。 至 t2线程 完成三项指令后,此时 Count内存 被修改成了1,现在执行 t1线程 的指令3(修改内存数据),但是我们 t1寄存器 存的值没有更新,仍然是 1。所在这里相当于我们 for 循环执行了两次,Count 却只增加了 1。这就是简单的线程安全问题。

        如何解决以上问题?其实有两种办法,(1)设置两个变量分别记录两个线程的值,最后在整合。(2)使用 synchronized 同步锁。

        想要了解更多关于线程安全的问题,请继续阅读本篇文章 ~


前期回顾:认识多线程


目录

前言

预备知识

Thread 类的属性与方法

Thread 的构造⽅法

 Thread 的常⻅属性

后台线程与前台线程

理解前台线程与后台线程

线程的生命周期

线程的操作

启动一个线程                                                                                                                   

中断⼀个线程

等待⼀个线程

获取当前线程引⽤

预备知识


Thread 类的属性与方法

介绍

        Thread 类是 JVM ⽤来管理线程的⼀个类,换句话说,每个线程都有⼀个唯⼀的 Thread 对象与之关联。

Thread 的构造⽅法

方法说明
Thread()创建线程对象
Thread(Runnable target)使用 Runnable 对象创建线程对象
Thread(String name)创建线程对象,并命名
Thread(ThreadGroup group,Runnable target)使用 Runnable 对象创建线程对象,并命名

        以上一二种方法在上期内容中有介绍,现在详细介绍由我们自己命名的线程该如何观察调试:

    public class Dome4 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(()->{
            while(true){
                System.out.println("Hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        },"自定义线程");
        thread.start();

        while(true){
            System.out.println("Hello main");
            Thread.sleep(1000);
        }

    }
}

        我们可以使用 JDK文件夹 中的 jconsole 命令观察线程,通过上图所示自命名的线程就已经创建出来了。这种方法可以用来调试线程。

        ThreadGroup 是线程组,把多个线程放在一组里,方便统一的设置线程的一些属性,但是现在很少使用线程组。线程的相关属性也用的不是太多,应该就是调试线程的时候需要用到。现在更多的是会使用线程池。

 Thread 的常⻅属性

• ID是线程的唯⼀标识,不同线程不会重复(JVM自动分配,无需手动创建)
• 名称是各种调试⼯具⽤到(自命名的名称,未自命名的进程默认以 Thread-0、Thread-1等进行)
• 状态表⽰线程当前所处的⼀个情况,下⾯我们会进⼀步说明
• 优先级⾼的线程理论上来说更容易被调度到
• 关于后台线程,需要记住⼀点:JVM会在⼀个进程的所有⾮后台线程结束后,才会结束运⾏
• 是否存活,即简单的理解,为 run() ⽅法是否运⾏结束了

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(true){
                System.out.println("Hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t1.start();
        // 获取线程属性
        System.out.println("线程ID:"+t1.getId());
        System.out.println("线程名称:"+t1.getName());
        System.out.println("线程状态:"+t1.getState());
        System.out.println("线程优先级:"+t1.getPriority());
    }

输出结果:

Hello Thread
线程ID:30
线程名称:Thread-0
线程状态:TIMED_WAITING
线程优先级:5
Hello Thread
Hello Thread
...

后台线程与前台线程

后台线

        所谓的后台线程也叫守护线程,是指在程序运行的时候在后台提供的一种通用的服务线程,并且这种线程并不属于程序不可或缺的部分。因此,当所有的前台进程结束时,程序也就终止了,同时会杀死进程中的所有后台进程。

前台线

        所谓的前台线程,是指在执行过程中能够影响进程结束的就是前台线程。比如,执行的我们 main 方法就是一个前台线程。 

public class Demo6 {
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(true){
                System.out.println("Hello Thread~");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t1.start();
    }
}
Hello Thread~
Hello Thread~
Hello Thread~
Hello Thread~
Hello Thread~
...

        以上是我们最常见的代码,其实我们的 t1 是一个前台线程,因为它是一个死循环阻止了程序的结束。那么如何让它变成后台线程呢?Java 提供了 setDaemon() 方法,只需传个 true 即可将前台线程变成后台线程。

        此时,t1线程 被设置为后台线程,一旦 前台线程main 完成工作,就没有什么能够阻止程序终止了。因为此时除了后台线程之外,已经没有前台进程在运行了。

        我们可以对 main进程 进行短暂休眠 sleep() ,就可以观察所有后台进程启动后的结果:

理解前台线程与后台线程

        我们举个生活中的例子 -- 酒桌文化

        在我们的酒桌文化上:如果是程序员(下属)喝趴下了,可以选着溜走,但是酒局继续。我们把程序员看作是后台线程,后台线程不影响程序的运行。以例子来讲 “这个酒局就算不没有你,也能继续喝”。        但是如果是领导喝趴下了,说要离开,那么宴席就是真正的结束了。就算程序员跳出来说,还想再吃点,也不行。我把领导看作是前台线程,前台线程影响程序的运行。以例子来讲 “这个酒局就是因为领导而设置的,领导说喝到什么时候就什么时候”。

线程的生命周期

        在代码中,创建的 new Thread 对象的生命周期与内核中的实际的线程的生命周期是不一定一样的。可能会出现 new Thread 对象仍然存在,但是内核线程不存在了这种情况。这种情况主要是两种因数导致的:

调用 start  之前,因为此时内核中还没有创建线程,但是 Thread 对象已经创建了
线程 run 运行完毕后,内核的线程已经没有了,但是 Thread 对象仍然还在

        这个时候我们就可以使用 isAlive 进行区分,如果返回值为 true 则表明内核的线程存在,反之则不存在:

public class Demo6 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 3; i++) {
                System.out.println("Hello Thread~");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        System.out.println("线程是否存活"+t1.isAlive());
        t1.start();
        System.out.println("线程是否存活"+t1.isAlive());
        Thread.sleep(4000);
        System.out.println("线程是否存活"+t1.isAlive());
    }
}

打印结果:

线程是否存活false
Hello Thread~
线程是否存活true
Hello Thread~
Hello Thread~
线程是否存活false

线程的操作


启动一个线程                                                                                                                   

        之前我们已经看到了如何通过覆写 run ⽅法创建⼀个线程对象,但线程对象被创建出来并不意味着线程就开始运⾏了,而是要调用 start 才能真正的运行线程。

模拟面试:start 与 run 之间的差别是什么呢?为什么 start 只能调用一次呢?

start 与 run 之间的差别  

start()方法是Java线程约定的内置方法,能够确保代码在新的线程上下文中运行
start()方法能够调用系统方法,真正在系统内核中创建线程(创建PCB,加入到链表中)。run()方法是我们自己写的代码,很显然没有这个能力
如果直接调用run()方法,那么它只是一个普通的方法调用,程序中依然只有一个主线程,并且只能顺序执行,需要等待run()方法执行结束后才能继续执行后面的代码

我们创建线程的目的是为了更充分地利用CPU资源,如果直接调用run()方法,就失去了创建线程的意义

start 不能运行两次的原因:

public class Demo7 {
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{});
        t1.start();
        t1.start();
    }
}

         通过以上代码的执行结果,我们可以看到当 start 运行两次后会抛出运行时异常。这里就涉及到 “线程状态问题”,由于 Java 中希望一个 Thread 对象,只能对应一个系统中的线程。因此就会在 start 中,根据线程状态做出判定。

        如果 Thread 对象没有 start ,此时的状态就是一个 NEW(新建)状态,接下来就可以顺利的调用 satrt ,如果已经调用过 satrt 就会进入其他状态。只要不是 NEW 状态,接下来执行 start 都会抛出异常。关于线程状态待会会讲。

中断⼀个线程

        当一个线程的 run 方法返回时(执行了方法体中的最后一条语句后,执行 return 语句返回),或者出现方法未捕获的异常,这个线程将终止。假设某个场景一个线程A运行中需要另一个线程B结束,Java 中并不是直接终止线程B,而是通过一些办法让线程B的 run 方法执行完毕。

public class Demo8 {
    private static boolean isQuit = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 =new Thread(()->{
            while(!isQuit){
                System.out.println("Hello World~");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1 线程执行结束");
        });
        t1.start();
        Thread.sleep(2000);
        System.out.println("main 线程尝试终止 t1 线程");
        isQuit = true;
    }
}

程序打印的结果 

Hello World~
Hello World~
main 线程尝试终止 t1 线程
t1 线程执行结束

        我们可以看到看我们的 main 终止 t1线程时,程序就已经结束了。这是因为 t1 是前台线程,但我们终止 t1 线程时,程序中唯一的前台线程 main 已经执行完毕,所以程序结束。

        Java 中提供了一个 interrupt 方法来请求终止一个线程当一个线程调用 interrupt 方法时,就会设置线程的中断状态。这是每个线程都有的一个boolean标志。各线程都应该不时的检查这个标志,以判断线程是否中断。

        为了确定是否设置了中断状态,首先调用静态方法 Thread.currentThread(获取本对象的实例),来获取当前线程,然后再调用 isInterrupted 方法。

        将上述代码改造一下就变成这样:

public class Demo8 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 =new Thread(()->{
            Thread currentThread = Thread.currentThread();
            while(!currentThread.isInterrupted()){
                System.out.println("Hello World~");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t1 线程执行结束");
        });
        t1.start();
        Thread.sleep(3000);
        System.out.println("main 线程尝试终止 t1 线程");
        t1.interrupt();
    }
}

方法说明
public void interrupt()中断对象关联的线程
public static boolean interrupted()判断当前线程的中断标志位是否设置
public boolean isinterrupted()判断对象关联的线程的标志位是否设置

        但是此时代码就会抛出一个异常,这是为什么呢? 

        由于判定 isInterrupted 和执行打印这两个操作实在是太快了,因此整个循环花费的时间主要是在 sleep 的 1000 毫秒上,当我们调用 Interrupt 的时候,大概率 t1线程还处于 sleep 状态。此处 Interrupt 不仅仅能设置标志位,还能把 sleep 操作 “唤醒”。

        如果线程被阻塞,就无法检查中断状态。在一个被 sleep 或者 wait 调用阻塞的线程上调用 interrupt 方法时,那个阻塞调用将会被一个 InterruptedException 异常中断。

        InterruptedException 异常主要是因为 catch 中默认代码再次抛出的异常引起的,再次抛出的异常没人处理 ,就最终到了JVM这一层,进程就直接异常终止了。

        此时 sleep 被唤醒了,触发了异常被 catch 住了。虽然我们将 catch 的抛出异常屏蔽之后,程序没有抛出异常,但是线程t1没有被终止,这是什么情况呢?我们接着来看

        如果在每次工作迭代之后都需要调用 sleep 方法(或者其他中断方法),isInterrupted 检查是既没有必要也没有用处的。如果设置了中断状态,此时倘若用了 sleep 方法,它不会休眠,而是清除中断状态,并抛出 InterruptedException。这就是上述程序没有被中断的原因。

         解决以上问题共有三种 方法

《1》我们可以在 catch 中调用 Thread.currentThread().interrupt() 来设置中断状态。这样一来调用者就可以检测中断状态。

                catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.out.println("执行到 catch 操作");
                }

运行结果:

Hello World~
Hello World~
Hello World~
main 线程尝试终止 t1 线程
执行到 catch 操作
t1 线程执行结束

《2》我们可以在 catch 中使用 renturn 来终止循环。

                catch (InterruptedException e) {
                    System.out.println("执行到 catch 操作");
                    return;
                }

《3》更好的选着是:用 thow InterruptedException 标记你的方法,并去掉 try 语句块。这样调用者就可以捕获这个异常了。

等待⼀个线程

        操作系统中,针对多线程的执行,是一个 “随机调度,抢占执行” 的过程。线程等待机就是在确认两个线程的 “结束顺序”。因为多线程是并发执行的,无法确定两个现场的执行顺序,但是可以控制,谁先结束,谁后结束。让后结束的线程等待先结束的线程即可。因此我们可以让后结束的线程进入阻塞,让先结束的线程真的结束了,才结束阻塞

public class Demo10 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            for (int i = 0; i < 3; i++) {
                System.out.println("这是线程 t");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t 结束");
        });
        t.start();
        System.out.println("main 线程开始等待");
        t.join();
        System.out.println("main 线程等待结束");
    }
}

打印结果: 

main 线程开始等待
这是线程 t
这是线程 t
这是线程 t
线程t 结束
main 线程等待结束

        因为我们使用了 t.join 说明其他线程要等待 t线程 运行结束,此时 main线程 阻塞等待,当 t线程 执行完毕,join才会返回,我们的 main线程 才开始继续执行。

        问:如果 t线程 已经结束了还会对 main线程造成阻塞吗?

  public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            for (int i = 0; i < 3; i++) {
                System.out.println("这是线程 t");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t 结束");
        });
        t.start();
        Thread.sleep(4000);
        System.out.println("main 线程开始等待");
        t.join();
        System.out.println("main 线程等待结束");
    }
}

打印结果:

这是线程 t
这是线程 t
这是线程 t
线程t 结束
main 线程开始等待
main 线程等待结束

        根据上述例子:我们可以发现当 t1线程 结束后,main 线程立刻就结束了,说明当一个线程结束后,使用 join 无法对其他线程造成影响。

一个线程等待多个线程

public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 3; i++) {
                System.out.println("这是线程 t1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t1 结束");
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5; i++) {
                System.out.println("这是线程 t2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t2 结束");
        });

        t1.start();
        t2.start();
        System.out.println("main 线程开始等待");
        t1.join();
        t2.join();
        System.out.println("main 线程等待结束");
    }
}

运行结果:

main 线程开始等待
这是线程 t1
这是线程 t2
这是线程 t1
这是线程 t2
这是线程 t1
这是线程 t2
这是线程 t2
线程t1 结束
这是线程 t2
线程t2 结束
main 线程等待结束

        上述代码逻辑是:main线程等待 t1、t2线程,由于程序是并发进行的,所以前 3s t1、t2 是一起计时的,当 t1线程 执行完毕,t2 线程只需在执行剩余的时间即可。当所有被等待线程执行完后,main 线程就可以开始运行了。

        那么如果是 t2线程 等待 t1线程 该如何写呢? -- 注意,被等待线程想要阻塞谁,就在阻塞线程中调用自己的 join 即可。

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 3; i++) {
                System.out.println("这是线程 t1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t1 结束");
        });

        Thread t2 = new Thread(()->{

            try {
                t1.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            for (int i = 0; i < 5; i++) {
                System.out.println("这是线程 t2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程t2 结束");
        });

        t1.start();
        t2.start();
        System.out.println("main 线程开始等待");
        t1.join();
        t2.join();
        System.out.println("main 线程等待结束");
    }
}

运行结果:

main 线程开始等待
这是线程 t1
这是线程 t1
这是线程 t1
线程t1 结束
这是线程 t2
这是线程 t2
这是线程 t2
这是线程 t2
这是线程 t2
线程t2 结束
main 线程等待结束
方法说明
public void join()等待进程结束
public void join(long millis)等待线程结束,最多等 millis 毫秒
public void join(long millis, int nanos)同理,可以更高精度

        我们以上的 join 都是无参版本,这意味着一定要等待这个线程执行完毕才能取消阻塞,那么如果被等待线程出现了一些问题,不能正常的结束,那么一直阻塞无法执行其他操作了。

        join 还提供了带参数的版本,说明到达一定时间被等待线程没有结束,那么阻塞线程就不等了直接开始运行。

获取当前线程引⽤

方法说明
Thread.currentThread()返回当前线程对象的引用
public class Demo11 {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        System.out.println(mainThread.getName()+"线程");
        Thread t1 = new Thread(()->{
            Thread t1Thread = Thread.currentThread();
            System.out.println(t1Thread.getName()+"线程");
        },"t1线程");
        t1.start();
    }
}

运行结果: 

main线程
t1线程线程


http://www.kler.cn/news/339040.html

相关文章:

  • 【C++打怪之路Lv7】-- 模板初阶
  • Git管理远程仓库
  • Windows系统编程(三)线程并发
  • [SQL] 数据定义语言
  • 23.1 k8s监控中标签relabel的应用和原理
  • vulnhub靶场之hackableIII
  • Linux防火墙-案例(二)snatdnat
  • qt 10.8作业
  • Css flex布局下 两端对齐与居中对齐
  • 在Vue中使用ECharts与v-if的问题及解决方案
  • 基于java+springboot的旅游信息网站、旅游景区门票管理系统设计与实现
  • SpringCloud Alibaba - Eureka注册中心,Nacos配置中心
  • 【技术详解】SpringMVC框架全面解析:从入门到精通(SpringMVC)
  • jvm里的metaspace oom 排查问题思路-使用MAT
  • 【Transformer 模型中的投影层,lora_projection是否需要?】
  • date:10.4(Content:Mr.Peng)( C language practice)
  • 学生家长必备,中小学课本教材电子书批量下载工具
  • 卡顿丢帧分析
  • 渐开线起始圆和基圆之间有约束关系吗?
  • CVE-2024-9014 pgAdmin4 OAuth2 client ID与secret敏感信息泄漏漏洞