深入浅出Java的多线程编程——第二篇

简介: 深入浅出Java的多线程编程——第二篇

让我们来回顾一下,第一篇多线程的内容:

1. 多线程:

(1)线程的概念

(2)进程和线程的区别

(3)Java代码如何创建线程

①继承Thread重写run

②实现Runnable接口重写run,将该实现类作为参数传给Thread的构造方法

③继承Thread,匿名内部类

④实现Runnable,匿名内部类

⑤lambda表达式

2. Thread的常用属性

start方法,真正从系统这里,创建一个线程,新的线程将会执行run方法。

run方法:表示线程的入口方法是啥(线程启动起来,要执行哪些逻辑)(run方法不是让程序猿调用的,要交给系统去自动调用)【换个角度理解:我们可以把线程的run方法理解为main方法,都是系统去自动调用】

1. 中断一个线程

李四一旦进到工作状态,他就会按照行动指南上的步骤去进行工作,不完成是不会结束的。但有时我们需要增加一些机制,例如老板突然来电话了,说转账的对方是个骗子,需要赶紧停止转账,那张三该如何通知李四停止呢?这就涉及到我们的停止线程的方式了。

本质上来说,让一个线程终止,办法就一种,让该线程的入口方法执行完毕!也就是让run跑完!

目前常见的有以下两种方式:

  • 通过共享的标记来进行沟通
  • 调用 interrupt() 方法来通知【记住,只是通知而已】

示例1:使用自定义的变量来作为标志位

  • 需要给标志位上加 volatile 关键字(这个关键字的功能后面介绍).
public static volatile boolean isQuit = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while (!isQuit){
                System.out.println(Thread.currentThread().getName() + " : 别管我,我忙着转正!");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + " : 啊!险些误了大事");
        });
        System.out.println(Thread.currentThread().getName() + " : 让李四开始转账");
        t.start();
        Thread.sleep(10*1000);
        System.out.println(Thread.currentThread().getName() + " : 老板来电话了,得赶紧通知李四对方是个骗子!");
        isQuit = true;
    }

这里的   public static volatile boolean isQuit = false; 为什么需要加static,因为main函数被static修饰,所以在main内部用到的成员变量要加static

示例2:使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位。

1.1 中断的API

  • Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记。
方法 说明
pubilc void interrupt() 中断对象关联的线程,如果线程正在阻塞,会把线程唤醒,则以异常的方式通知,然后吧标志位设置为true
public static boolean interrupted() 判断当前线程的中断标志位是否设置,调用后清除标志位
public boolean isInterrupted() 判断对象关联的线程的标志位是否设置,调用后不清除标志位
  • 使用 thread 对象的 interrupted() 方法通知线程结束.
public class Thread4 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("hello t");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

我们可以发现,调用t.interrupt方法的时候,线程并没有真的结束,而是打印了个异常信息,又继续执行了

1.2 小结

interrupt方法的作用:

(1)设置标志位为true

(2)如果该线程正在阻塞中(比如执行了sleep)此时就会把阻塞状态唤醒,通过抛出异常的方式让sleep立即结束

注意:一个非常重要的问题,当sleep被唤醒的时候(只有sleep被唤醒才会重置标志位),sleep自动地把isInterrupted标志位给清空了(true - > false),这导致下次循环,循环仍然可以继续执行了~~

有的开关,是按下之后,就按下去了

有的开关,是按下去之后,自动弹起(sleep就属于这种)

一种极端的情况:

如果设置interrupt的时候,恰好sleep刚醒,这个时候赶巧了,执行到下一轮循环条件就直接结束了。但是这种概率非常低,毕竟sleep的时间已经占据了整个循环体的99.999%的时间了

如果需要结束循环,就得在catch中搞个break

public class Thread4 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("hello t");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
                   break;
               }
           }
        });
        t.start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t.interrupt();
    }
}

小结一下:

如果sleep执行的时候看到这个标志位是false,sleep正常进行休眠操作

如果当前的标志位为true

sleep无论是刚刚执行还是已经执行了一般,都会触发两件事

(1)立即抛出异常

(2)清空标志位为false

再下次循环,到sleep

由于当前标志位本身是false,就啥也不干~~

总结到这里,就有小伙伴有疑问了,为什么sleep要清空标志位呢?

目的就是为了让线程自身能够对于线程何时结束,有一个更明确的控制~~

当前,interrupt方法,效果不是让线程立即结束,而是告诉他,你该结束了,至于他是否真的要立即结束还是等会结束,都是由它本线程的代码来控制~~,interrupt只是通知,而非“命令”!

有的朋友就好奇了,我如果不加sleep,这些能令线程阻塞的代码,那是不是就能让该线程直接结束呢?对的,是可以,但是工作中没人会这么写代码,一个不可控的线程是多么可怕!

这里就可以又引出一个问题,java为啥不强制制定“命令结束”的操作呢?

只要调用interrupt就立即结束?

答:主要是设定成这种,对线程来说非常不友好~,线程t何时结束,一定是t自己要最清楚,交给t自身来决定比较好!

2. 等待一个线程

有时,我们需要等待一个线程完成它的工作后,才能进行自己的下一步工作。例如,张三只有等李四转账成功,才决定是否存钱,这时我们需要一个方法明确等待线程的结束。

public class Thread5 {
    public static void main(String[] args) throws InterruptedException {
        Runnable run = () -> {
          for(int i = 0;i < 3;i++){
              try {
                  System.out.println(Thread.currentThread().getName() + " : 我正在工作!");
                  Thread.sleep(1000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
            System.out.println(Thread.currentThread().getName() + " : 我的工作结束了!");
        };
        Thread t1 = new Thread(run,"李四");
        Thread t2 = new Thread(run,"张三");
        System.out.println("李四先工作");
        t1.start();
        t1.join();
        System.out.println("李四做完了,张三开始工作!");
        t2.start();
        t2.join();
        System.out.println("王五做完了,张三开始工作!");
        System.out.println("两人都工作结束了!");
    }
}

在main线程中调用t1.join()表示main线程要等t1线程跑完,main线程才可以继续执行。

(1)main线程调用t1.join()的时候,如果t1还在运行,此时main线程阻塞,知道t执行完毕(t1的run执行完了),main线程才从阻塞中解除,才继续执行。

(2)main线程调用t.join()的时候,如果t已经结束了,此时join就不会阻塞,会立即执行下去。

如果把两个join方法注释掉,就会CPU的抢占式调用的典型例子:

public class Thread5 {
    public static void main(String[] args) throws InterruptedException {
        Runnable run = () -> {
          for(int i = 0;i < 3;i++){
              try {
                  System.out.println(Thread.currentThread().getName() + " : 我正在工作!");
                  Thread.sleep(1000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
            System.out.println(Thread.currentThread().getName() + " : 我的工作结束了!");
        };
        Thread t1 = new Thread(run,"李四");
        Thread t2 = new Thread(run,"张三");
        System.out.println("李四先工作");
        t1.start();
//        t1.join();
        System.out.println("李四做完了,张三开始工作!");
        t2.start();
//        t2.join();
        System.out.println("王五做完了,张三开始工作!");
        System.out.println("两人都工作结束了!");
    }
}

2.1 等待的API

方法 说明
public void join() 等待线程结束
public void join(long millis) 等待线程结束,最多等 millis 毫秒
public void join(long millis, int nanos) 同理,但可以更高精度

3. 线程的状态

3.1 贯彻线程的所有状态

线程的状态是一个枚举类型 Thread.State

public class Thread6 {
    public static void main(String[] args) {
        for (Thread.State state:Thread.State.values()) {
            System.out.println(state);
        }
    }
}

  • NEW: 安排了工作, 还未开始行动
  • RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.
  • BLOCKED: 这几个都表示排队等着其他事情
  • WAITING: 这几个都表示排队等着其他事情
  • TIMED_WAITING: 这几个都表示排队等着其他事情
  • TERMINATED: 工作完成了.

3.2 线程状态和状态转移的意义

大家不要被这个状态转移图吓到,我们重点是要理解状态的意义以及各个状态的具体意思。

举个栗子:

      刚把李四、王五找来,还是给他们在安排任务,没让他们行动起来,就是 NEW 状态;

当李四、王五开始去窗口排队,等待服务,就进入到 RUNNABLE 状态。该状态并不表示已经被银行工。

      作人员开始接待,排在队伍中也是属于该状态,即可被服务的状态,是否开始服务,则看调度器的调度;

      当李四、王五因为一些事情需要去忙,例如需要填写信息、回家取证件、发呆一会等等时,进入

       BLOCKED 、 WATING 、 TIMED_WAITING 状态,至于这些状态的细分,我们以后再详解;

如果李四、王五已经忙完,为 TERMINATED 状态。所以,之前我们学过的 isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。

4. 多线程带来的的风险-线程安全 (重点)

本质是因为线程之间的调度顺序的不确定性

4.1 观察线程不安全

public class Thread7 {
    static int count = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0;i < 5000;i++){
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0;i < 5000;i++){
                count++;
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

 

由于当前这两线程调度的顺序是无序的~~

你也不知道这两线程自增的过程中,到底经历了什么

有多少次是“顺序执行”,有多少次是“交错执行”不知道!

得到的结果是啥也就是不确定的!

这里就引出了一个问题,出现bug之后,得到的结果一定是 <= 10000,或者结果一定是 >= 5000?

CPU调用是以原语为单位的!

4.2 线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

4.3 线程不安全的原因

4.3.1 修改共享数据

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

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

4.3.2 原子性

什么是原子性

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

那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。有时也把这个现象叫做同步互斥,表示操作是互相排斥的。

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

比如刚才我们看到的 n++,其实是由三步操作组成的:

  1. 从内存把数据读到 CPU
  2. 进行数据更新
  3. 把数据写回到 CPU

不保证原子性会给多线程带来什么问题

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

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

4.3.3 可见性

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

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.

目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

  1. 线程之间的共享变量存在 主内存 (Main Memory).
  2. 每一个线程都有自己的 "工作内存" (Working Memory) .
  3. 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  4. 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.

1) 初始情况下, 两个线程的工作内存内容一致.

2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步.

这个时候代码中就容易出现问题.

此时引入了两个问题:

  • 为啥要整这么多内存?
  • 为啥要这么麻烦的拷来拷去?

1) 为啥整这么多内存?

实际并没有这么多 "内存". 这只是 Java 规范中的一个术语, 是属于 "抽象" 的叫法.

所谓的 "主内存" 才是真正硬件角度的 "内存". 而所谓的 "工作内存", 则是指 CPU 的寄存器和高速缓存.

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

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

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了,效率就大大提高了

那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??

答案就是一个字: 贵

值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘。

对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜。

4.3.4 代码顺序性

什么是代码重排序

一段代码是这样的:

  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递

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

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

重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论

4.4 解决之前的线程不安全问题

public class Thread7 {
    static int count = 0;
    public static void main(String[] args) {
        Object o = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (o) {
                for(int i = 0;i < 5000;i++){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (o) {
                for(int i = 0;i < 5000;i++){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}
相关文章
|
6天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
4天前
|
安全 程序员 API
|
1天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
12 4
|
2天前
|
Java API Apache
Java编程如何读取Word文档里的Excel表格,并在保存文本内容时保留表格的样式?
【10月更文挑战第29天】Java编程如何读取Word文档里的Excel表格,并在保存文本内容时保留表格的样式?
14 5
|
1天前
|
消息中间件 供应链 Java
掌握Java多线程编程的艺术
【10月更文挑战第29天】 在当今软件开发领域,多线程编程已成为提升应用性能和响应速度的关键手段之一。本文旨在深入探讨Java多线程编程的核心技术、常见问题以及最佳实践,通过实际案例分析,帮助读者理解并掌握如何在Java应用中高效地使用多线程。不同于常规的技术总结,本文将结合作者多年的实践经验,以故事化的方式讲述多线程编程的魅力与挑战,旨在为读者提供一种全新的学习视角。
15 3
|
2天前
|
安全 Java 调度
Java中的多线程编程入门
【10月更文挑战第29天】在Java的世界中,多线程就像是一场精心编排的交响乐。每个线程都是乐团中的一个乐手,他们各自演奏着自己的部分,却又和谐地共同完成整场演出。本文将带你走进Java多线程的世界,让你从零基础到能够编写基本的多线程程序。
11 1
|
6天前
|
缓存 Java 调度
Java中的多线程编程:从基础到实践
【10月更文挑战第24天】 本文旨在为读者提供一个关于Java多线程编程的全面指南。我们将从多线程的基本概念开始,逐步深入到Java中实现多线程的方法,包括继承Thread类、实现Runnable接口以及使用Executor框架。此外,我们还将探讨多线程编程中的常见问题和最佳实践,帮助读者在实际项目中更好地应用多线程技术。
12 3
|
9天前
|
Java 数据处理 开发者
Java多线程编程的艺术:从入门到精通####
【10月更文挑战第21天】 本文将深入探讨Java多线程编程的核心概念,通过生动实例和实用技巧,引导读者从基础认知迈向高效并发编程的殿堂。我们将一起揭开线程管理的神秘面纱,掌握同步机制的精髓,并学习如何在实际项目中灵活运用这些知识,以提升应用性能与响应速度。 ####
31 3
|
10天前
|
Java
Java中的多线程编程:从入门到精通
本文将带你深入了解Java中的多线程编程。我们将从基础概念开始,逐步深入探讨线程的创建、启动、同步和通信等关键知识点。通过阅读本文,你将能够掌握Java多线程编程的基本技能,为进一步学习和应用打下坚实的基础。
|
3月前
|
算法 Java 开发者
Java 编程入门:从零到一的旅程
本文将带领读者开启Java编程之旅,从最基础的语法入手,逐步深入到面向对象的核心概念。通过实例代码演示,我们将一起探索如何定义类和对象、实现继承与多态,并解决常见的编程挑战。无论你是编程新手还是希望巩固基础的开发者,这篇文章都将为你提供有价值的指导和灵感。