0
点赞
收藏
分享

微信扫一扫

建造者模式:构建复杂对象的优雅之道

冶炼厂小练 04-07 14:01 阅读 1

为什么我们要学习并发编程?

最直白的原因,因为面试需要,我们来看看美团和阿里对 Java 岗位的 JD:

在这里插入图片描述
在这里插入图片描述

从上面两大互联网公司的招聘需求可以看到, 大厂的 Java 岗的并发编程能力属于标配。

而在非大厂的公司, 并发编程能力也是面试的极大加分项, 而工作时善用并发编程则可以极大提升程序员在公司的技术话语权。

为什么开发中需要并发编程?

从岗位 JD 其实就能看出来,并发编程和性能优化是密切相关的,使用并发编程可以做到:

1、加快响应用户的时间

比如我们经常用的迅雷下载,都喜欢多开几个线程去下载,谁都不愿意用一个线程去下载。

为什么呢?

我们在做程序开发的时候更应该如此,特别是我们做互联网项目, 网页的响应时间若提升 1s,如果流量大的话,就能增加不少转换量。

做过高性能 web前端调优的都知道,要将静态资源地址用两三个子域名去加载,为什么?

因为每多一个子域名,浏览器在加载你的页面的时候就会多开几个线程去加载你的页面资源,提升网站的响应速度。

2、使你的代码模块化,异步化,简单化

例如我们实现电商系统,下订单和给用户发送短信、邮件就可以进行拆分, 将给用户发送短信、邮件这两个步骤独立为单独的模块, 并交给其他线程去执行。

这样既增加了异步的操作, 提升了系统性能, 又使程序模块化,清晰化和简单化。

3、充分利用 CPU 的资源

目前市面上没有 CPU 的内核不是多核的,比如这台机器

在这里插入图片描述

多核下如果还是使用单线程的技术做思路明显就 out 了,无法充分利用 CPU 的多核特点。

如果设计一个多线程的程序的话,那它就可以同时在多个 CPU 的多 个核的多个线程上跑,可以充分地利用 CPU,减少 CPU 的空闲时间,发挥它的运算 能力,提高并发量。

当然有同学会有疑问,单核 CPU 呢? 单核 CPU 一样可以利用到并发编程的 好处吗?

当然可以,用我们平时常用的 QQ 之类的聊天程序来举例,当我们用 QQ 聊天时,其实程序要做好几件事。

比如:接受我们的键盘输入,把输入的信 息通过网络发给对方, 接受对方通过网络发来的信息,把对方的信息显示在屏幕 上, 很多的时候, 这些事情是可以同时发生的。

如果程序不能利用并发编程同时处理,我们和对方的通话就只能一问一答的方式进行了。

我们怎么学并发编程?

对于没有或者很少接触并发编程的同学,建议主要掌握并发里的基础概念基础用法并发工具类并发容器的用法

已经有较多并发编程经验建议适当学习并发编程的底层原理与设计思想:

比如,synchronized 底层原理,常用并发设计模式,AQS源码等等

给大家一个并发编程学习路线图,以供查缺补漏:

![在这里插入图片描述](https://img-blog.csdnimg.cn/direct/720efb247c4547819651eec19d0da8ae.png#pic_center)

下面带大家一起学习并发编程的基础概念和基础用法

基础概念

什么是进程和线程

进程

进程就是我们常听说的是应用程序, 也就是 app ,由指令和数据组成。

但是当我们不运行一个具体的 app 时,这些应用程序就是放在磁盘(也包括 U 盘、远程网络 存储等等)上的一些二进制的代码。

一旦我们运行这些应用程序,指令要运行, 数据要读写,就必须将指令加载至 CPU,数据加载至内存。

在指令运行过程中 还需要用到磁盘、网络等设备,从这种角度来说, 进程就是用来加载指令、管理内存、管理IO的。

当一个程序被运行, 从磁盘加载这个程序的代码至内存, 这时就开启了一个 进程。

进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程 (例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如 网易云音乐、 360 安全卫士等) 。显然,程序是死的、静态的,进程是活的、动态 的。

进程可以分为系统进程用户进程

凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由你启动的进程。

在这里插入图片描述

线程

一个机器中肯定会运行很多的程序, CPU 又是有限的, 怎么让有限的 CPU 运行这么多程序呢?

这就需要一种机制在程序之间进行协调,也就所谓 CPU 调度。 线程则是 CPU 调度的最小单位。

线程必须依赖于进程而存在,线程是进程中的一个实体,是 CPU 调度和分 派的基本单位,它是比进程更小的、能独立运行的基本单位。

线程自己基本上不拥有系统资源,,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈), 但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

一个进程可以拥有多个线程, 一个线程必须有一个父进程。线程, 有时也被称为轻量级进程 (Lightweight Process ,LWP),早期 Linux 的线程实现几乎就是复用的进程,后来才独立出自己的 API。

Java 线程的无处不在

Java 中不管任何程序都必须启动一个 main函数的主线程;Java Web 开发里面的定时任务、定时器、JSP 和 Servlet、异步消息处理机制,远程访问接口 RM 等, 任何一个监听事件,onclick 的触发事件等都离不开线程和并发。

大厂面试题:进程间的通信

同一台计算机的进程通信称为 IPC(Inter-process communication),不同计算机之间的进程通信被称为 R(mote)PC,需要通过网络,并遵守共同的协议。

大厂常见的面试题:进程间通信有几种方式?

1、管道, 分为匿名管道(pipe)及命名管道(named pipe)

匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外, 它还允许无亲缘关系进程间的通信。

2、信号(signal)

信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式, 用于通知进程有某事件发生, 一个进程收到一个信号与处理器 收到一个中断请求效果上可以说是一致的。

3、消息队列(message queue)

消息队列是消息的链接表,它克服了上两 种通信方式中信号量有限的缺点, 具有写权限得进程可以按照一定得规则向消息 队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。

4、共享内存(shared memory)

可以说这是最有用的进程间通信方式。它 使得多个进程可以访问同一块内存空间, 不同进程可以及时看到对方进程中对共 享内存中数据得更新。这种方式需要依靠某种同步操作, 如互斥锁和信号量等。

5、信号量(semaphore)

主要作为进程之间及同一种进程的不同线程之间 得同步和互斥手段。

6、套接字(socket)

这是一种更为一般得进程间通信机制, 它可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用 Unix domain socket(比如同一机器中 MySQL 中的控制台 mysql shell 和 MySQL 服 务程序的连接),这种方式不需要经过网络协议栈, 不需要打包拆包、计算校验 和、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。

CPU 核心数和线程数的关系

前面说过, 目前主流 CPU 都是多核的, 线程是 CPU 调度的最小单位。

同一 时刻, 一个 CPU 核心只能运行一个线程,也就是 CPU 内核和同时运行的线程数 是 1:1 的关系, 也就是说 8 核 CPU 同时可以执行 8 个线程的代码。

但 Intel 引入超线程技术后,产生了逻辑处理器的概念, 使核心数与线程数形成 1:2 的关系。

在我们前面的 Windows 任务管理器贴图就能看出来,内核数是6,而逻辑处理器 数是 12。

在 Java 中提供了 Runtime.getRuntime().availableProcessors(),可以让我们获 取当前的 CPU 核心数,。

获得当前的 CPU 核心数在并发编程中很重要,并发编程下的性能优化往往 和 CPU 核心数密切相关。

上下文切换(Context switch)

既然操作系统要在多个进程(线程) 之间进行调度, 而每个线程在使用 CPU 时总是要使用 CPU 中的资源,比如 CPU 寄存器和程序计数器。

这就意味着,操作系统要保证线程在调度前后的正常执行,所以, 操作系统中就有上下文切换的概念,它是指 CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。

上下文是CPU 寄存器和程序计数器在任何时间点的内容。

在这里插入图片描述

寄存器是CPU 内部的一小部分非常快的内存(相对于CPU 内部的缓存和CPU 外部较慢的RAM 主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。

程序计数器是一种专门的寄存器,它指示CPU 在其指令序列中的位置,并 保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系 统。

上下文切换可以更详细地描述为内核(即操作系统的核心)对 CPU 上的进程 (包括线程)执行以下活动:

  1. 暂停一个进程的处理, 并将该进程的 CPU 状态(即上下文)存储在内存中的 某个地方

  2. 从内存中获取下一个进程的上下文,并在 CPU 的寄存器中恢复它

  3. 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进 程。

从数据来说, 以程序员的角度来看, 是方法调用过程中的各种局部的变量与资源;

以线程的角度来看, 是方法的调用栈中存储的各类信息。

引发上下文切换的原因一般包括: 线程、进程切换、系统调用等等。

上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回拷贝。就 CPU 时间而言, 一次上下文切换大概需要 5000~20000 个时钟周期, 相对一个简单指令几个乃至十几个左右的执行时钟周期, 可以看出这个成本的巨大。

并行和并发

我们举个例子,如果有条高速公路 A 上面并排有 8 条车道,那么最大的并行车辆就是 8 辆。

此条高速公路 A 同时并排行走的车辆小于等于 8 站在操作系统的角度, 进程是程序运行资源分配(以内存为主)的最小单位辆的时候,车辆就可以并行运行。

CPU 也是这个原理,一个 CPU 相当于一个高速公路 A,核心数或者线程数就相当于并排可以通行的车道;而多个 CPU 就相当于并排有多条高速公路,而每个高速公路并排有多个车道。

当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少? 离开了单位时间其实是没有意义的。

综合来说:

并发 Concurrent

指应用能够交替执行不同的任务, 比如单 CPU 核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务, 以达到" 同时执行效果",其实并不是真正的同时执行。只是计算机的速度太快,我们无法察觉到而已。

在这里插入图片描述

并行 Parallel

指应用能够同时执行不同的任务,例如:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行

在这里插入图片描述

两者区别:一个是交替执行,一个是同时执行

认识 Java 里的线程

Java 程序天生就是多线程的

一个 Java 程序从 main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与, 但实际上 Java 程序天生就是多线程程序, 因为执行 main() 方法的是一个名称为 main 的线程。

public static void main(String[] args) {
        System.out.println("当前运行的main线程name:"+Thread.currentThread().getName());
    }

运行结果:

在这里插入图片描述

而一个 Java 程序的运行就算是没有用户自己开启的线程,实际也有有很多 JVM 自行启动的线程, 一般来说有:

[6] Monitor Ctrl-Break //监控 Ctrl-Break 中断信号的

[5] Attach Listener //内存 dump,线程 dump,类信息统计, 获取系统属性等 [4] Signal Dispatcher // 分发处理发送给 JVM 信号的线程

[3] Finalizer // 调用对象 finalize 方法的线程

[2] Reference Handler//清除 Reference 的线程

[1] main //main 线程, 用户程序入口

尽管这些线程根据不同的 JDK 版本会有差异, 但是依然证明了 Java 程序天生就是多线程的。

Java线程的实现方式

刚刚看到的线程都是 JVM启动的系统线程,我们学习并发编程肯定是希望自己能操控线程。

所以我们先来看看如何启动线程:

方式1:使用 Thread类或继承Thread类

// 创建线程对象
 Thread t = new Thread() {
   public void run() {
      // 要执行的任务
    }
 };
 // 启动线程
 t.start();

方式2:实现 Runnable 接口配合Thread

把【线程】和【任务】(要执行的代码)分开

  • Thread 代表线程

  • Runnable 可运行的任务(线程要执行的代码)

public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            public void run() {
                // 要执行的任务
            }
        };
        // 创建线程对象
        Thread t = new Thread(runnable);
        // 启动线程
        t.start();
    }

方式3:使用有返回值的 Callable

class CallableTask implements Callable<Integer{
    @Override
    public Integer call() throws Exception {
        return new Random().nextInt();
  }
}
public static void main(String[] args) {
        //创建线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        //提交任务,并用 Future提交返回结果
        Future<Integer> future = service.submit(new CallableTask());
  }

方式4:使用 lambda

new Thread(()-> System.out.println(Thread.currentThread().getName())).start();

面试题:新启线程有几种方式?

这个问题的答案其实众说纷纭,有 2 种, 3 种, 4 种等等答案.

建议比较好的回答是,按照 Java 源码中 Thread 上的注释:

![在这里插入图片描述](https://img-blog.csdnimg.cn/direct/a72b8420ed3642f7a3721c7d3e49b59e.png#pic_center)

官方说法是在 Java 中有两种方式创建一个线程用以执行, 一种是派生自 Thread 类,另一种是实现 Runnable 接口。

当然本质上 Java 中实现线程只有一种方式, 都是通过 new Thread()创建线程对象,调用 Thread#start 启动线程。

至于基于 callable 接口的方式,因为最终是要把实现了 callable 接口的对象通过 FutureTask 包装成 Runnable,再交给 Thread 去执行,所以这个其实可以和 实现 Runnable 接口看成同一类。

而线程池的方式, 本质上是池化技术, 是资源的复用, 和新启线程没什么关 系。

Java线程如何中止

1、线程自然终止

要么是 run方法 执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。

这样的线程就会自然终止。

2、手动stop线程

暂停、恢复和停止操作对应在线程 Thread 的 API 就是 suspend() 、resume() 和 stop()。

但是这些 API 是过期的,也就是不建议使用的。

不建议使用的原因主要有

以 suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如 锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。

public class SuspendThreadDemo {  
    public static void main(String[] args) throws InterruptedException {  
        Thread thread = new Thread(() -> {  
            System.out.println("线程开始执行...");  

            // 模拟线程正在执行一些任务  
            for (int i = 0; i < 5; i++) {  
                System.out.println("线程正在执行任务 " + i);  
                try {  
                    Thread.sleep(500); // 让线程休眠500毫秒  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  

            System.out.println("线程任务执行完毕...");  
        });  

        thread.start(); // 启动线程  

        // 让主线程休眠1秒钟,以确保上面启动的线程已经开始执行  
        Thread.sleep(1000);  

        // 调用suspend()方法暂停线程  
        // 注意:此方法已被标记为过时,因为它可能导致死锁和其他同步问题。  
        // 强烈建议不要使用它,而是使用其他同步机制,如wait/notify或Lock。  
        thread.suspend();  
        System.out.println("线程已被暂停...");  

        // 让主线程休眠2秒钟,然后恢复被暂停的线程  
        Thread.sleep(2000);  
        thread.resume(); // 注意:resume()方法也已被标记为过时。  
        System.out.println("线程已恢复...");  
    }  
}

由于suspend()resume()方法可能导致死锁和其他同步问题,因此它们已经被标记为过时。作为替代方案,你可以使用wait()notify()方法(或notifyAll()方法),或者java.util.concurrent.locks包中的Lock接口来实现线程间的同步。这些方法提供了更灵活、更安全的线程控制机制。

同样, stop()方 法在终结一个线程时也不会保证线程的资源正常释放, 通常是没有给予线程完成资源释放工作的机会。

stop()方法被调用时,它会立即停止线程的执行,并且不会给线程任何机会来清理其资源或完成其执行路径上的重要操作。

这可能导致对象处于不一致的状态,比如锁没有被正确释放文件没有被正确关闭,或者事务没有被正确提交等。。

public class StopThreadDemo {  
    public static void main(String[] args) throws InterruptedException {  
        Thread thread = new Thread(() -> {  
            System.out.println("线程开始执行...");  

            try {  
                // 模拟线程正在执行一些任务  
                for (int i = 0; i < 10; i++) {  
                    System.out.println("线程正在执行任务 " + i);  
                    Thread.sleep(500); // 让线程休眠500毫秒  
                }  
            } catch (InterruptedException e) {  
                // 当线程被stop()方法中断时,可能会抛出InterruptedException  
                System.out.println("线程被异常中断...");  
            }  

            System.out.println("线程正常结束...");  
        });  

        thread.start(); // 启动线程  

        // 让主线程休眠2秒钟,以确保上面启动的线程已经开始执行  
        Thread.sleep(2000);  

        // 调用stop()方法强制停止线程  
        // 注意:此方法已被废弃,因为它可能导致数据不一致和其他严重问题。  
        // 强烈建议不要使用它,而是使用其他线程协作机制,如interrupt()和标志变量。  
        thread.stop(); // 不安全的线程停止方法  
        System.out.println("线程已被强制停止...");  
    }  
}

正因为 suspend()、 resume()和 stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法。

3、采用中断线程中止

在Java中,interrupt()方法是一种推荐的方式来请求线程中断其执行。

stop()方法不同,interrupt()方法不会强制停止线程,而是设置线程的中断状态。

线程可以检查这个中断状态,并决定如何响应中断请求。

这通常涉及到捕获InterruptedException,清理资源,并安全地停止执行。

public class InterruptThreadDemo {  
    public static void main(String[] args) throws InterruptedException {  
        Thread thread = new Thread(() -> {  
            System.out.println("线程开始执行...");  

            while (!Thread.currentThread().isInterrupted()) {  
                try {  
                    // 模拟线程正在执行一些任务  
                    System.out.println("线程正在执行任务...");  
                    Thread.sleep(1000); // 让线程休眠1000毫秒  
                } catch (InterruptedException e) {  
                    // 当线程在等待、睡眠或其他可以中断的状态中被中断时,会抛出此异常  
                    System.out.println("线程收到中断请求,开始处理中断...");  
                    // 在这里可以添加清理资源的代码  
                    break// 跳出循环,结束线程的执行  
                }  
            }  

            System.out.println("线程已安全停止...");  
        });  

        thread.start(); // 启动线程  

        // 让主线程休眠3秒钟,以确保上面启动的线程已经开始执行  
        Thread.sleep(3000);  

        // 调用interrupt()方法中断线程  
        // 注意:这是一种安全的方式来请求线程停止其执行,因为它允许线程进行必要的清理工作  
        thread.interrupt(); // 发送中断请求给线程  
        System.out.println("已发送中断请求给线程...");  

        // 等待线程结束  
        thread.join(); // 确保主线程等待被中断线程的结束  
        System.out.println("所有线程都已结束...");  
    }  
}

主线程休眠3秒钟后,调用interrupt()方法向新线程发送中断请求。新线程在下次迭代时会检测到中断状态,并响应中断请求。最后,主线程使用join()方法等待新线程安全地结束。

为什么建议使用interrupt()中断线程?

  1. 安全性interrupt()方法允许线程在收到中断请求时执行必要的清理工作,如关闭文件、释放锁或回滚事务等,从而确保资源的一致性和系统的稳定性。
  2. 协作性:中断是一种协作机制,线程可以选择如何响应中断请求。线程可以决定在何时以及如何停止其执行,而不是被强制立即停止。
  3. 灵活性:与 stop()方法相比, interrupt()方法更加灵活。线程可以根据其当前状态和业务逻辑来决定是立即停止还是稍后停止,或者在停止前完成一些重要操作。

深入理解Java线程run()和start()方法

Thread 类是Java 里对线程概念的抽象,可以这样理解:我们通过 new Thread() 其实只是 new 出一个 Thread 的实例,还没有操作系统中真正的线程挂起钩来。

只有执行了 start()方法后,才实现了真正意义上的启动线程。

从 Thread 的源码可以看到, Thread 的 start 方法中调用了 start0()方法,而 start0()是个 native 方法, 这就说明 Thread#start 一定和操作系统是密切相关的。

    /**
     * Causes this thread to begin execution; the Java Virtual Machine
     * calls the <code>run</code> method of this thread.
     * <p>
     * The result is that two threads are running concurrently: the
     * current thread (which returns from the call to the
     * <code>start</code> method) and the other thread (which executes its
     * <code>run</code> method).
     * <p>
     * It is never legal to start a thread more than once.
     * In particular, a thread may not be restarted once it has completed
     * execution.
     *
     * @exception  IllegalThreadStateException  if the thread was already
     *               started.
     * @see        #run()
     * @see        #stop()
     */

    public synchronized void start() {
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */

        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */

        group.add(this);

        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */

            }
        }
    }

    private native void start0();

start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现 的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常

而 run 方法是业务逻辑实现的地方, 本质上和任意一个类的任意一个成员方 法并没有任何区别, 可以重复执行,也可以被单独调用。

深入学习Java线程

线程的状态/生命周期

Java 中线程的状态分为 6 种:

  1. **初始(NEW)**:新创建了一个线程对象,但还没有调用 start()方法。

  2. **运行(RUNNABLE)**:Java 线程中将就绪(ready)和运行中(running)两种 状态笼统的称为“运行”。

  1. **阻塞(BLOCKED)**:表示线程阻塞于锁。

  2. **等待(WAITING)**:进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)。

  3. **超时等待(TIMED_WAITING)**:该状态不同于 WAITING,它可以在指定的时 间后自行返回。

  4. **终止(TERMINATED)**:表示该线程已经执行完毕。

在这里插入图片描述

线程(Thread)常用方法

1、sleep方法

  • 调用 sleep 会让当前线程从 Running 进入TIMED_WAITING状态,不会释放对象锁其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出InterruptedException,并且会清除中断标志。

  • 睡眠结束后的线程未必会立刻得到执行

  • sleep当传入参数为0时,和yield相同yield方法

public class SleepExample {  
    public static void main(String[] args) {  
        // 打印当前线程名  
        System.out.println("Current thread: " + Thread.currentThread().getName());  

        // 模拟一些工作  
        System.out.println("Working...");  

        try {  
            // 使当前线程睡眠2秒  
            System.out.println("Thread is going to sleep...");  
            Thread.sleep(2000); // 2000 milliseconds = 2 seconds  
            System.out.println("Thread woke up!");  
        } catch (InterruptedException e) {  
            // 如果线程在睡眠期间被中断,则进入此异常处理块  
            System.out.println("Thread sleep was interrupted!");  
            // 通常,我们应该在此处恢复中断状态,以便上层调用者能够感知到中断  
            Thread.currentThread().interrupt();  
        }  

        // 模拟线程继续执行其他工作  
        System.out.println("Continuing work...");  
    }  
}

最佳使用实践及注意事项:

  1. 异常处理Thread.sleep()方法会抛出InterruptedException,这是一个检查型异常,因此必须捕获或向上抛出。当线程在等待、睡眠或者其他可以中断的状态时被中断,就会抛出此异常。

  2. 恢复中断状态:如果在catch块中捕获到InterruptedException,通常的做法是重新设置中断状态,因为中断状态在抛出异常时会被清除。通过调用Thread.currentThread().interrupt()来恢复中断状态。

  3. 精确计时Thread.sleep()方法不保证精确的计时,它可能受到系统计时器和调度器精度的影响。因此,不应该依赖Thread.sleep()来进行精确的时间控制。

  4. 避免滥用:不要滥用Thread.sleep()作为等待条件或同步机制。它主要用于模拟短暂的等待或让出CPU执行权,而不是用于同步或协调线程之间的活动。对于复杂的同步需求,应该使用java.util.concurrent包中的工具类,如SemaphoreCountDownLatchCyclicBarrier等。

  5. 资源管理:虽然Thread.sleep()方法不会释放任何锁资源,但在调用它之前确保当前线程没有持有任何不必要的锁是很重要的,以避免死锁或性能问题。

  6. 多线程环境:记住Thread.sleep()是静态方法,它作用于当前执行的线程,而不是调用该方法的Thread对象所代表的线程。在多线程环境中要特别小心,确保你操作的是正确的线程。

2、yield方法

  • yield会释放CPU资源,让当前线程从 Running 进入 Runnable状态,让优先级更高(至少是相同)的线程获得执行机会,不会释放对象锁;

  • 假设当前进程只有main线程,当调用yield之后,main线程会继续运行,因为没有比它优先级更高的线程;

  • 具体的实现依赖于操作系统的任务调度器

public class YieldExample {  
    public static void main(String[] args) {  
        Thread thread1 = new Thread(() -> {  
            for (int i = 0; i < 5; i++) {  
                System.out.println("Thread 1: " + i);  
                if (i % 2 == 0) {  
                    // 当i为偶数时,提示线程让步  
                    Thread.yield();  
                }  
            }  
        });  

        Thread thread2 = new Thread(() -> {  
            for (int i = 0; i < 5; i++) {  
                System.out.println("Thread 2: " + i);  
                // 注意:这里没有调用Thread.yield(),所以线程2会尽可能连续执行  
            }  
        });  

        // 启动两个线程  
        thread1.start();  
        thread2.start();  
    }  
}

最佳使用实践及注意事项:

  1. 使用场景Thread.yield()通常用于编写并发程序时,当线程没有更多重要的工作要做,或者希望给其他线程执行机会时使用。它特别适用于那些执行大量计算且不需要持续占用CPU的线程。

  2. 不保证效果:调用Thread.yield()并不保证会导致当前线程立即停止执行,也不保证其他线程会立即获得执行权。线程调度器可能完全忽略这个提示,具体行为取决于JVM的实现和当前系统的状态。

  3. 非阻塞方法Thread.yield()是一个非阻塞方法,调用它不会使线程进入等待或阻塞状态。线程在调用此方法后仍然处于就绪状态,可以立即恢复执行。

  4. 避免过度使用:过度使用Thread.yield()可能会导致线程频繁地放弃CPU,从而降低程序的总体性能。通常,在编写多线程程序时,应该优先考虑使用更高级的并发控制工具,如java.util.concurrent包中的类。

  5. 不是同步机制Thread.yield()不是一个同步机制,它不能用来确保线程之间的正确执行顺序。如果需要同步线程,应该使用synchronized关键字、Lock接口或其他同步工具。

  6. 谨慎在生产环境中使用:由于Thread.yield()的行为具有不确定性和不可预测性,因此在生产环境中应谨慎使用。在大多数情况下,通过合理的线程设计和使用现有的并发工具类,可以实现更好的线程管理和性能优化。

3、join()方法

Thread.join()方法是Java线程中的一个重要方法,它允许一个线程等待另一个线程完成执行。当调用某个线程的join()方法时,调用线程会被阻塞,直到被调用的线程终止。

public class JoinExample {  
    public static void main(String[] args) {  
        Thread thread1 = new Thread(() -> {  
            System.out.println("Thread 1 is running.");  
            try {  
                // 模拟线程1执行一些任务  
                Thread.sleep(2000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println("Thread 1 has finished.");  
        });  

        Thread thread2 = new Thread(() -> {  
            System.out.println("Thread 2 is waiting for Thread 1.");  
            try {  
                // 线程2调用线程1的join()方法,等待线程1完成  
                thread1.join();  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println("Thread 2 detected that Thread 1 has finished.");  
            // 线程2在线程1完成后继续执行  
        });  

        // 启动线程  
        thread1.start();  
        thread2.start();  
    }  
}

最佳使用实践及注意事项:

  1. 使用场景Thread.join()常用于确保线程执行的顺序。例如,如果线程B需要在线程A完成其任务后才能开始执行,那么可以在线程B中调用线程A的join()方法。

  2. 阻塞调用线程:调用thread1.join()会导致线程2(调用线程)被阻塞,直到thread1完成执行。这意味着在线程1完成之前,线程2不会继续执行join()方法之后的代码。

  3. 处理InterruptedExceptionjoin()方法可能会抛出InterruptedException,这是一个检查型异常,必须捕获或声明抛出。这个异常表示调用线程在等待过程中被中断了。在捕获此异常后,应该适当地处理中断,比如通过恢复中断状态或执行其他清理操作。

  4. 指定等待时间join()方法有两个重载版本,允许指定一个最大等待时间。例如,thread1.join(1000)表示线程2最多等待线程1一秒钟。如果在这个时间内线程1没有结束,线程2将不再阻塞并继续执行。这可以用于避免长时间的等待或死锁。

  5. 注意资源竞争:过度使用join()可能会导致资源竞争和性能问题,因为多个线程可能同时尝试加入同一个线程,导致上下文切换增加。在设计多线程应用时,应该尽量减少不必要的线程间依赖。

  6. 不适用于大量线程:如果有大量的线程需要互相等待,使用join()可能会导致性能下降和复杂的同步问题。在这种情况下,应该考虑使用更高级的并发控制工具,如java.util.concurrent包中的ExecutorServiceFutureCountDownLatch等。

  7. 不要在构造函数或初始化块中调用:避免在线程的构造函数或初始化块中调用join(),因为这可能导致意外的行为或死锁。最好在线程启动后,在明确的同步点调用join()

4、stop()方法

stop()方法已经被jdk废弃,原因就是stop()方法太过于暴力,强行把执行到一半的线程终止。

Java线程的调度机制

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式分两种,分别是协同式线程调度和抢占式线程调度

协同式线程调度

线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另

外一个线程上。

抢占式线程调度

每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java中,

Thread.yield()可以让出执行时间,但无法获取执行时间)。

线程执行时间系统可控,也不会有一个线程导致整个进程阻塞。

希望系统能给某些线程多分配一些时间,给一些线程少分配一些时间,可以通过设置线程优

先级来完成。

Java语言一共10个级别的线程优先级Thread.MIN_PRIORITY~Thread.MAX_PRIORITY

在两线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行。

但优先级并不是很靠谱,因为Java线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统。

public class SellTicketDemo implements Runnable{
    //车票
    private int ticket;
    public SellTicketDemo(){
        this.ticket=100;
    }
    @Override
    public void run() {
        while (ticket>0){
            synchronized (this){
                if(ticket>0){
                    try{
                        Thread.sleep(2);
                    }catch (InterruptedException ex){
                        ex.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName()+":正在执行操作,余票:"+ticket--);
                }
            }
            Thread.yield();
        }
    }

    public static void main(String[] args) {
        SellTicketDemo demo=new SellTicketDemo();
        Thread thread1=new Thread(demo,"thread1");
        Thread thread2=new Thread(demo,"thread2");
        Thread thread3=new Thread(demo,"thread3");
        Thread thread4=new Thread(demo,"thread4");
        //priority优先级默认是5,最低1,最高10
        thread1.setPriority(Thread.MAX_PRIORITY);
        thread2.setPriority(Thread.MAX_PRIORITY);
        thread3.setPriority(Thread.MIN_PRIORITY);
        thread4.setPriority(Thread.MIN_PRIORITY);
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

执行结果:

在这里插入图片描述

从执行结果可以看出并没有严格按照我们设置的优先级执行

Java线程的实现模式

为什么 Java 线程调度是抢占式调度?这需要我们了解 Java 中线程的实现模式。

我们已经知道线程其实是操作系统层面的实体, Java 中的线程怎么和操作系 统层面对应起来呢?

任何语言实现线程主要有三种方式:

1、使用内核线程实现(1:1 实现)

2、使用用 户线程实现(1:N 实现)

3、使用用户线程加轻量级进程混合实现(N:M 实现)

1、内核线程实现

使用内核线程实现的方式也被称为 1: 1 实现。

内核线程(Kernel-Level Thread ,KLT) 就是直接由操作系统内核(Kernel , 下称内核) 支持的线程,这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将线程的任务映射到各个处理器上。

优势:由于内核线程的支持, 每个线程都成为一个独立的调度单元, 即使其中某一 个在系统调用中被阻塞了, 也不会影响整个进程继续工作,相关的调度工作也不 需要额外考虑,已经由操作系统处理了。

局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户 态(User Mode)和内核态(Kernel Mode)中来回切换。其次, 每个语言层面的线程都需要有一个内核线程的支持, 因此要消耗一定的内核资源(如内核线程的 栈空间),因此一个系统支持的线程数量是有限的。

2、用户线程实现

严格意义上的用户线程指的是完全建立在用户空间的线程库上, 系统内核不能感知到用户线程的存在及如何实现的。

用户线程的建立、同步、销毁和调度完全在用户态中完成, 不需要内核的帮助。

如果程序实现得当, 这种线程不需要切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的 线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。

用户线程的优势在于不需要系统内核支援, 劣势也在于没有系统内核的支援, 所有的线程操作都需要由用户程序自己去处理。

线程的创建、销毁、切换和调度 都是用户必须考虑的问题, 而且由于操作系统只把处理器资源分配到进程, 那诸 如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问 题解决起来将会异常困难, 甚至有些是不可能实现的。 因为使用用户线程实现 的程序通常都比较复杂, 所以一般的应用程序都不倾向使用用户线程。Java 语言曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点 的编程语言又普遍支持了用户线程,譬如 Golang

3、混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将内核线程与用户线程一起使用的实现方式, 被称为 N:M 实现。 在这种混合实 现下, 既存在用户线程, 也存在内核线程。

用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析构等操作依然廉价, 并且可以支持大规模的用户线程并发。

同样又可以使用内核提供的线程调度功能及处理器映射, 并且用户线程的系 统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的数量比是不定的,是 N:M 的关系。

4、Java 线程的实现

Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的, 但从 JDK 1.3 起, 主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型。

以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线 程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。

所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的, 所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以 Java 优先级并不是特别靠谱。

Java中的协程

为什么要引入协程?

随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。

比如, 互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不 同机器上的大量服务共同协作来实现,也就是我们常说的微服务, 这种服务细 分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服 务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服 务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服务被阻塞而出现等待。

Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾, 1:1 的内核线程模型是如今 Java 虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂, 系统能容纳的线程数量也很有限。

以 前处理一个请求可以允许花费很长时间在单体应用中, 具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多 的前提下, 用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销, 这就会造成严重的浪费。

另外我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十 个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来,但其中的切换损耗也是相当可观的。

这样的话,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一 个非常可行的方案。

其次, Go 语言等支持用户线程等新型语言给 Java 带来了巨大的压力, 也使 得 Java 引入用户线程成为了一个绕不开的话题。

什么是协程?

为什么用户线程又被称为协程呢?我们知道, 内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉 吗?

但是, 一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上, 则可以通过很多手段来缩减这些开销。

由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling) 的,所以它有了一个别名—— “协程”(Coroutine) 完整地做调用栈的保护、 恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。

协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB ,此外内核数据结构(Kernel Data Structures) 还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支 持协程的应用中, 同时并存的协程数量可数以十万计。

协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这 些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。

总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的 延迟)。

纤程-Java 中的协程

在 JVM 的实现上,以 HotSpot 为例, 协程的实现会有些额外的限制, Java调用栈跟本地调用栈是做在一起的。

如果在协程中调用了本地方法, 还能否正 常切换协程而不影响整个线程?

另外, 如果协程中遇传统的线程同步措施会怎 样?

所以 Java 开发组就 Java中协程的实现也做了很多努力, OpenJDK 在 2018 年 创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber) ”这个 名字。

Loom 项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟机中并存, 可以在程序中同时使用。

新模型有意地保持了与目前线程模型相似的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。

目前 Java 中比较出名的协程库是 Quasar(Loom 项目的 Leader 就 是 Quasar 的作者 Ron Pressler), Quasar 的实现原理是字节码注入,在字节码 层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚拟机的现场保护虽然能够工作,但影响性能。

Java中的守护线程

Daemon(守护) 线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候, Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。

Daemon 线程被用作完成支持性工作, 但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时, 不能依靠 finally 块中 的内容来确保执行关闭或清理资源的逻辑。

public class DaemonThreadExample {  

    public static void main(String[] args) {  
        Thread daemonThread = new Thread(() -> {  
            while (true) {  
                try {  
                    Thread.sleep(1000);  
                    System.out.println("Daemon thread is running...");  
                } catch (InterruptedException e) {  
                    System.out.println("Daemon thread interrupted.");  
                }  
            }  
        });  

        // 设置为守护线程  
        daemonThread.setDaemon(true);  

        // 启动守护线程  
        daemonThread.start();  

        // 主线程休眠3秒钟,然后退出  
        try {  
            Thread.sleep(3000);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  

        System.out.println("Main thread is finished.");  
    }  
}

线程间是如何进行通信、协调、协作的?

很多的时候, 孤零零的一个线程工作并没有什么太多用处, 更多的时候, 我们是很多线程一起工作,而且是这些线程间进行通信, 或者配合着完成某项工作, 这就离不开线程间的通信和协调、 协作。

下面我们分别来看下线程间的几种通信方式:

1、管道输入输出流

我们已经知道,进程间有好几种通信机制,其中包括了管道,其实 Java 的线程里也有类似的管道机制, 用于线程之间的数据传输, 而传输的媒介为内存。

设想这么一个应用场景:通过 Java 应用生成文件, 然后需要将文件上传到 云端,比如:

我们一般的做法是, 先将文件写入到本地磁盘, 然后从文件磁盘读出来上传 到云盘,但是通过 Java 中的管道输入输出流一步到位,则可以避免写入磁盘这 一步。

Java 中的管道输入/输出流主要包括了如下 4 种具体实现:

  1. PipedOutputStream

  2. PipedInputStream

  3. PipedReader

  4. PipedWriter

    public class Piped {
        public static void main(String[] args) throws Exception {
            PipedWriter out = new PipedWriter();
            PipedReader in = new PipedReader();
            /* 将输出流和输入流进行连接,否则在使用时会抛出IOException*/
            out.connect(in);
            Thread printThread = new Thread(new Print(in), "PrintThread");
            printThread.start();
            int receive = 0;
            try {
                /*将键盘的输入,用输出流接受,在实际的业务中,可以将文件流导给输出流*/
                while ((receive = System.in.read()) != -1){
                    out.write(receive);
                }
            } finally {
                out.close();
            }
        }

        static class Print implements Runnable {
            private PipedReader in;
            public Print(PipedReader in) {
                this.in = in;
            }

            @Override
            public void run() {
                int receive = 0;
                try {
                    /*输入流从输出流接收数据,并在控制台显示
                    *在实际的业务中,可以将输入流直接通过网络通信写出 */

                    while ((receive = in.read()) != -1){
                        System.out.print((char) receive);
                    }
                } catch (IOException ex) {
                }
            }
        }
    }

2、join 方法

Thread.join()

把指定的线程加入到当前线程, 可以将两个交替执行的线程合并为顺序执行。

比如在线程 B 中调用了线程 A 的 Join()方法, 直到线程 A 执行完毕后, 才会继续执行线程 B 剩下的代码。

面试题:现在有 T1、T2、T3 三个线程, 你怎样保证 T2 在 T1 执行完后执行, T3 在 T2 执行完后执行?

答:用 Thread#join 方法即可, 在 T3 中调用 T2.join,在 T2 中调用 T1.join。

public class ThreadOrderExecution {  

    public static void main(String[] args) throws InterruptedException {  
        Thread t1 = new Thread(() -> {  
            System.out.println("T1 is running");  
            // 模拟T1线程的工作  
            try {  
                Thread.sleep(1000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println("T1 finished");  
        });  

        Thread t2 = new Thread(() -> {  
            System.out.println("T2 is running");  
            // 模拟T2线程的工作  
            try {  
                Thread.sleep(1000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println("T2 finished");  
        });  

        Thread t3 = new Thread(() -> {  
            System.out.println("T3 is running");  
            // 模拟T3线程的工作  
            try {  
                Thread.sleep(1000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            System.out.println("T3 finished");  
        });  

        // 启动T1线程,并等待其完成  
        t1.start();  
        t1.join();  

        // 启动T2线程,并等待其完成  
        t2.start();  
        t2.join();  

        // 启动T3线程,并等待其完成(如果需要的话,也可以在这里调用t3.join(),但在主线程中通常不是必需的)  
        t3.start();  
    }  
}

3、synchronized 内置锁

线程开始运行, 拥有自己的栈空间, 就如同一个脚本一样, 按照既定的代码 一步一步地执行, 直到终止。

但是, 每个运行中的线程, 如果仅仅是孤立地运行, 那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作, 包括数据之间的共享,协同处理事情,这将会带来巨大的价值。

Java 支持多个线程同时访问一个对象或者对象的成员变量,但是多个线程同时访问同一个变量,会导致不可预料的结果。

关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用, 它主要确保多个线程在同一个时刻, 只能有一 个线程处于方法或者同步块中, 它保证了线程对变量访问的可见性和排他性, 使多个线程访问同一个变量的结果正确,它又称为内置锁机制。

对象锁和类锁:

对象锁是用于对象实例方法, 或者一个对象实例上的。

public class Counter {  
    private int count = 0;  

    // 使用synchronized修饰方法,以确保线程安全  
    public synchronized void increment() 
        
        count++;  
    }  

    public synchronized void decrement() {  
        count--;  
    }  

    public synchronized int getCount() {  
        return count;  
    }  
}


public class Main {  
    public static void main(String[] args) {  
        Counter counter = new Counter();  

        // 创建一个线程来增加计数器  
        Thread incrementThread = new Thread(() -> {  
            for (int i = 0; i < 5; i++) {  
                counter.increment();  
                System.out.println("Incremented. Count: " + counter.getCount());  
                try {  
                    Thread.sleep(100); // 暂停一段时间以便观察输出  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        });  

        // 创建一个线程来减少计数器  
        Thread decrementThread = new Thread(() -> {  
            for (int i = 0; i < 5; i++) {  
                counter.decrement();  
                System.out.println("Decremented. Count: " + counter.getCount());  
                try {  
                    Thread.sleep(100); // 暂停一段时间以便观察输出  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        });  

        // 启动线程  
        incrementThread.start();  
        decrementThread.start();  
    }  
}

类锁是用于类的静态 方法或者一个类的 class 对象上的。

public class Counter {  
    private static int count = 0;  

    // 使用synchronized修饰静态方法,以确保线程安全  
    public static synchronized void increment() {  
        count++;  
    }  

    public static synchronized void decrement() {  
        count--;  
    }  

    public static synchronized int getCount() {  
        return count;  
    }  
}

测试代码

public class Main {  
    public static void main(String[] args) {  
        // 创建一个线程来增加计数器  
        Thread incrementThread = new Thread(() -> {  
            for (int i = 0; i < 5; i++) {  
                Counter.increment();  
                System.out.println("Incremented. Count: " + Counter.getCount());  
                try {  
                    Thread.sleep(100); // 暂停一段时间以便观察输出  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        });  

        // 创建一个线程来减少计数器  
        Thread decrementThread = new Thread(() -> {  
            for (int i = 0; i < 5; i++) {  
                Counter.decrement();  
                System.out.println("Decremented. Count: " + Counter.getCount());  
                try {  
                    Thread.sleep(100); // 暂停一段时间以便观察输出  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }  
        });  

        // 启动线程  
        incrementThread.start();  
        decrementThread.start();  
    }  
}

在这个示例中,increment()decrement(), 和 getCount() 都是静态方法,并且都被标记为synchronized。这意味着在同一时间只有一个线程可以执行这些方法中的任何一个。当一个线程进入其中一个synchronized静态方法时,它会获取该类的类锁(也称为Class对象锁),并执行方法。其他尝试进入synchronized静态方法的线程将被阻塞,直到第一个线程退出该方法并释放锁。

错误的加锁导致锁失效的原因分析

下面我们来看一个锁失效的场景

/**
 * 类说明:错误的加锁和原因分析
 */

public class TestIntegerSyn {

    public static void main(String[] args) throws InterruptedException {
        Worker worker=new Worker(1);
        //Thread.sleep(50);
        for(int i=0;i<5;i++) {
            new Thread(worker).start();
        }
    }

    private static class Worker implements Runnable{

        private Integer i;
        private Object o = new Object();

        public Worker(Integer i) {
            this.i=i;
        }

        @Override
        public void run() {
            synchronized (i) {
                Thread thread=Thread.currentThread();
                System.out.println(thread.getName()+"--@"
                        +System.identityHashCode(i));
                i++;
                System.out.println(thread.getName()+"-------[i="+i+"]-@"
                        +System.identityHashCode(i));
                SleepTools.ms(3000);
                System.out.println(thread.getName()+"-------[i="+i+"]--@"
                        +System.identityHashCode(i));
            }

        }

    }

在这里插入图片描述

可以看到 i 的取值会出现乱序或者重复取值的现象

在这里插入图片描述

volatile,最轻量的通信/同步机制

volatile 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某 个变量的值,这新值对其他线程来说是立即可见的。

/**
 * 类说明:演示Volatile的提供的可见性
 */

public class VolatileCase {
    private static boolean ready;
    private static int number;

    private static class PrintThread extends Thread{
        @Override
        public void run() {
            System.out.println("PrintThread is running.......");
            while(!ready){
                //System.out.println("lll");
            };//无限循环
            System.out.println("number = "+number);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new PrintThread().start();
        SleepTools.second(1);
        number = 51;
        ready = true;
        SleepTools.second(5);
        System.out.println("main is ended!");
    }
}
}

不加 volatile 时,子线程无法感知主线程修改了 ready 的值,从而不会退出循环, 而加了 volatile 后,子线程可以感知主线程修改了 ready 的值,迅速退出循环。

但是 volatile 不能保证数据在多个线程下同时写时的线程安全.

volatile 最适用的场景:一个线程写,多个线程读。

等待(wait)/通知(notify)机制

线程之间相互配合,完成某项工作。

比如: 一个线程修改了一个对象的值, 而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程, 而最终执行又是另一个线程。

前者是生产者, 后者就是消费者, 这种模式隔离了 “做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地循环检查变量是否符合预期在 while 循环中设置不满足的条件, 如果条件满足则退出 while 循环,从而完成消费者的工作。

但是此方式却存在如下问题:

1)难以确保及时性。

2)难以降低开销。

等待/通知机制则可以很好的避免。

这种机制是指一个线程 A 调用了对象 O 的 wait()方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify()或者notifyAll()方法, 线程 A 收到通知后从对象 O 的 wait()方法返回, 进而执行后续操 作。上述两个线程通过对象 O 来完成交互, 而对象上的 wait()和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

notify():

通知一个在对象上等待的线程,使其从 wait 方法返回,而返回的前提是该线程 获取到了对象的锁,没有获得锁的线程重新进入 WAITING 状态。

notifyAll():

通知所有等待在该对象上的线程

wait()

调用该方法的线程进入 WAITING 状态, 只有等待另外线程的通知或被中断 才会返回. 需要注意,调用 wait()方法后,会释放对象的锁

wait(long)

超时等待一段时间,这里的参数时间是毫秒,也就是等待长达 n 毫秒,如果没有 通知就超时返回

wait (long,int)

对于超时时间更细粒度的控制,可以达到纳秒

等待和通知的标准范式

等待方遵循如下原则

1)获取对象的锁。

2)如果条件不满足, 那么调用对象的 wait()方法, 被通知后仍要检查条件。

3)条件满足则执行对应的逻辑。

通知方遵循如下原则

1)获得对象的锁。

2)改变条件。

3)通知所有等待在对象上的线程。

/**
 *类说明:快递实体类
 */

class Express {
    public final static String DIST_CITY = "ShangHai";
    public final static int TOTAL = 500;
    private int km ;/*快递运输里程数*/
    private String site;/*快递到达地点*/

    public Express() {
    }

    public Express(int km, String site) {
        this.km = km;
        this.site = site;
    }

    public void change(){
        if (km < TOTAL){
            km = km +100;
            System.out.println("the Km is "+this.km);
        }
        if(km >= TOTAL){
            site = DIST_CITY;
            System.out.println("the Express is arrived");
        }
    }

    /*线程等待公里的变化*/
    public synchronized void waitKm(){
        while(this.km <= TOTAL){
            try {
                wait();
                System.out.println("Map thread["
                        +Thread.currentThread().getId()
                        +"] wake,I will change db");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /*线程等待目的地的变化*/
    public synchronized void waitSite(){
        while(!this.site.equals(DIST_CITY)){
            try {
                wait();
                System.out.println("Notice User thread["+Thread.currentThread().getId()
                        +"] wake");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("the site is "+this.site+",I will call user");
    }
}

使用wait/notify的注意事项

  1. 别忘了锁: 线程必须要获得该对象的对象级 别锁 想象一下,你要进入一个房间(同步块或同步方法),而这个房间有一把锁。waitnotify就像房间里的小动作,你必须先进房间(获得锁),才能做这些小动作。如果你没拿锁就尝试做这些小动作,就会被告知“没资格”(抛出异常)。

  2. 找对门卫: 调用这些方法的实例必须是当前线程持有的锁对象 在对象上调用waitnotify时,这个对象就像是一个门卫。你要确保你对着正确的门卫打招呼(调用方法)。如果你找错了门卫,那么你的指令就不会被执行。

  3. 放下手里的东西: 释放锁和重新获取锁 当你调用wait时,就好像是手里拿了个东西进房间,然后想坐下休息一下。你必须先把手里的东西放下(释放锁),然后才能安心坐下。而被notify叫醒后,你又要重新拿起这个东西(重新获取锁),才能继续走出房间。

  4. 可能会被误叫: 虚假唤醒(Spurious Wakeups) 有时候,你可能会被提前叫醒(虚假唤醒),就像闹钟出了问题一样。所以,每次被叫醒后,最好再检查一下时间(条件),确认是不是真的到了该起床的时候。

  5. 别堵在门口: 尽量使用notifyAll 如果一堆人都在等同一个门卫叫他们(多个线程等待同一个对象的通知),最好用notifyAll来叫醒所有人,这样大家就可以有序地通过,而不会造成拥堵或者有人被遗漏。

  6. 小心打瞌睡: 死锁和活锁 在使用waitnotify时,要时刻保持警惕,不要让线程进入无法唤醒的“深度睡眠”(死锁)或者一直在忙却没有进展的“梦游状态”(活锁)。

  7. 礼貌中断
    如果别人在休息(线程在等待),而你突然有事要找他,记得礼貌地中断他,而不是直接把他拽起来。InterruptedException就是那个礼貌的中断信号,你需要妥善处理它。

  8. 别频繁敲门
    过度使用wait()和notify()可能会对性能产生负面影响,因为它们涉及到线程的挂起和唤醒,这是相对昂贵的操作。在可能的情况下,考虑使用更高效的并发控制机制。

扩展知识点:notify 和notifyAll 应该用谁

面试题

方法和锁

面试题:调用 yield() 、sleep() 、wait() 、notify()等方法对锁有何影响?

wait 和notify

面试题:为什么 wait 和 notify 方法要在同步块中调用?

原因

假如我们有两个线程, 一个消费者线程, 一个生产者线程。

生产者线程的任 务可以简化成将 count 加一,而后唤醒消费者;

消费者则是将 count 减一,而后 在减到 0 的时候陷入睡眠:

这里面有问题。什么问题呢?

生产者是两个步骤:

  1. count+1;

  2. notify();

消费者也是两个步骤:

  1. 检查 count 值;

  2. 睡眠或者减一;

万一这些步骤混杂在一起呢?

比如说,初始的时候 count 等于 0,这个时候消费者检查 count 的值,发现 count 小于等于 0 的条件成立;

就在这个时候, 发 生了上下文切换,生产者进来了,噼噼啪啪一顿操作,把两个步骤都执行完了, 也就是发出了通知, 准备唤醒一个线程。

这个时候消费者刚决定睡觉, 还没睡呢, 所以这个通知就会被丢掉。紧接着,消费者就睡过去了......

这里面有问题。什么问题呢?

生产者是两个步骤:

  1. count+1;

  2. notify();

消费者也是两个步骤:

  1. 检查 count 值;

  2. 睡眠或者减一;

万一这些步骤混杂在一起呢?

比如说,初始的时候 count 等于 0,这个时候 消费者检查 count 的值,发现 count 小于等于 0 的条件成立; 就在这个时候, 发 生了上下文切换,生产者进来了,噼噼啪啪一顿操作,把两个步骤都执行完了, 也就是发出了通知, 准备唤醒一个线程。这个时候消费者刚决定睡觉, 还没睡呢, 所以这个通知就会被丢掉。紧接着,消费者就睡过去了

在这里插入图片描述

这就是所谓的 lost wake up 问题。

那么怎么解决这个问题呢?

现在我们应该就能够看到,问题的根源在于,消费者在检查 count 到调用 wait()之间, count 就可能被改掉了。

这就是一种很常见的竞态条件。

很自然的想法是, 让消费者和生产者竞争一把锁, 竞争到了的, 才能够修改 count 的值。

面试题:为什么你应该在循环中检查等待条件?

处于等待状态的线程可能会收到错误警报和伪唤醒, 如果不在循环中检查等待条件, 程序就会在没有满足结束条件的情况下退出。

因此, 当一个等待线程醒来时,不能认为它原来的等待状态仍然是有效的,在 notify()方法调用之后和等待线程醒来之前这段时间它可能会改变。

这就是在循环中使用 wait()方法效果更好的原因。

举报

相关推荐

建造者模式

0 条评论