详细总结Java中的并发基础类Thread

在学习并发编程的时候,对着一门课程从头学习到尾,一头扎在了后续的各种并发工具类、容器中,从而导致我们忽视了最初的、最原始的那一个东西,Thread。一说起线程池、ThreadLocal,我倒是有挺多东西可以说,反过来说起Thread,却发现并没有自己想象中那么了解,今天我再次详细总结一下Thread,打牢基础。

线程与进程的区别

进程

进程在学习操作系统的时候学习过,它是一个块,里面包含了运行时需要的代码和数据。进程就是一个运行的应用程序,比如电脑上的软件,双击它就会运行一个或者多个进程。

线程

线程是体现在Java中的,包含了一条条的指令流。具体体现则是Thread类。而进程是静止的,只是作为线程的容器。所以,一个进程中存在多个线程。

查看进程、线程的方法

Windows

  • 任务管理器可以查看进程和线程数,也可以用来杀死线程
  • tasklist 查看进程
    • tasklist | findstr java 查看Java相关进程
  • taskkill < PID >杀死进程

Linux

  • ps -fe 查看所有进程

    • ps -fe | grep java 过滤不含Java的进程
  • ps -fT -p < PID > 查看某个进程< PID > 的所有线程

  • kill < PID >杀死进程

  • top 按大写H切换是否显示线程 (包含cpu 内存 占用情况)

  • top -H -p < PID > 查看某个进程 < PID > 的所有线程

Java

(Windows && Linux 皆可)

  • jps命令查看所有Java进程
  • jstack < PID > 查看某个Java进程(PID)的所有线程状态
  • jconsole 来查看某个Java进程中线程的运行情况(图形界面)

并行与并发

单核CPU下,线程其实只是串行执行的。操作系统有个一个组件叫任务调度器,它会更具任务调度算法,给每一个线程分配使用时间片,并且会不断的进行线程之间的切换,且由于切换时间极快,所以人感觉还是同时运行的。微观串行,宏观并行。一般这种线程轮流使用 CPU的做法成为并发。

什么是并行?

  • 并行(concurrent)同一时间动手做(doing)多件事的能力(可以同时做,同一时刻可以处理不同的事情)

什么是并发

  • 并发(parallel)同一时间应对(dealing with)多件事情的能力(同时做不了,同一时刻只能处理一件事)

应用

  • 异步调用。如果采用单线程,IO拷贝耗时长,在此期间代码只能暂停,处理不了其他的请求,这很不好。所以采用异步线程去处理耗时较长的任务,比如视频格式转换、Tomcat异步Servlet ,异步处理避免阻塞主线程,提高Tomcat服务器吞吐量
  • 充分利用多核CPU的优势,提高运行效率。在多核下,多线程明显提高了效率,但是在单核下,反而会因为上下文切换,多线程耗时反而更加久。

创建线程的方式

方式一:new Thread()

java 复制代码
//创建线程对象 有参构造可直接为线程命名
Thread t = new Thread("t1"){
  	@Override
    public void run(){
        //要执行的任务
    }  
};
//启动线程
t.start();

方式二:new Runnable()

java 复制代码
//创建任务对象
Runnable runnable = new Runnable() {
    @Override
    public void run() {

    }
};

//lambda
Runnable runnable = () -> {

};
//创建线程对象,参数1:任务对象,参数2:线程名字
Thread thread = new Thread(runnable,"t1");
//启动线程
thread.start();

总结

  • 方式一是把线程和任务合并到了一起,方法式把线程和任务分开了(重写run方法其实就是重写了Thread中run方法。在Thread里,实际上调用的是target的run方法。Runnable就是当做target对象传入进去)
  • 用Runnable更容易与线程池等高级API配合
  • 用Runnable让任务类脱离了Thread继承体系,更加灵活

方法三

java 复制代码
//可接受Callable类型参数,处理含有返回值的线程
new FutureTask<Integer>(new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        return null;
    }
});

//lambda写法
FutureTask<Integer> task = new FutureTask<>(() -> {
   
     return 100;
 });
Thread t3 = new Thread(task, "t3");
t3.start();
//主线程阻塞,同步等待结果并打印
Integer result = task.get();
log.debug("结果:{}",result);

什么是守护线程

  • 默认情况下,Java进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即便守护线程的代码没有执行完,也会强制结束。
  • 使用线程的 setDaemon(true)将线程设置为守护线程
  • 垃圾回收器线程就是一种守护线程
  • Tomcat中的Acceptor 和Poller 线程都是守护线程,所以Tomcat接收到shutdown命令后,不会等待它们处理完当前请求

线程的状态

五种状态

从操作系统层面来描述

  • 【初始状态】仅是在语言层面创建了线程对象,还未于操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由CPU调度执行
  • 【运行状态】指获取了CPU时间片中运行中的状态
    • 当CPU时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下切换
  • 【阻塞状态】
    • 如果调用了阻塞API,如BIO读写文件,这时线程实际不会用到CPU,会导致上下文切换,进入【阻塞状态】
    • 等BIO操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要他们一直不唤醒,调度器就一直不会考虑如何调度他们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其他状态

六种状态

在Thread类的源码中定义的

  • NEW 线程刚被创建,但是还没有调用 start() 方法

  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的

  • 【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为

​ 是可运行)

  • BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分

  • TERMINATED 当线程代码运行结束

wait / notify / notifyAll 方法的使用注意事项?

为什么 wait 必须在 synchronized 保护的同步代码中使用?

首先,我们来看第一个问题,为什么 wait 方法必须在 synchronized 保护的同步代码中使用?

我们先来看看 wait 方法的源码注释是怎么写的。

java 复制代码
"wait method should always be used in a loop:

 synchronized (obj) {

     while (condition does not hold)

         obj.wait();

     ... // Perform action appropriate to condition

}

This method should only be called by a thread that is the owner of this object's monitor."

英文部分的意思是说,在使用 wait 方法时,必须把 wait 方法写在 synchronized 保护的 while 代码块中,并始终判断执行条件是否满足,如果满足就往下继续执行,如果不满足就执行 wait 方法,而在执行 wait 方法之前,必须先持有对象的 monitor 锁,也就是通常所说的 synchronized 锁。那么设计成这样有什么好处呢?

我们逆向思考这个问题,如果不要求 wait 方法放在 synchronized 保护的同步代码中使用,而是可以随意调用,那么就有可能写出这样的代码。

java 复制代码
class BlockingQueue {

    Queue<String> buffer = new LinkedList<String>();

    public void give(String data) {

        buffer.add(data);

        notify();  // Since someone may be waiting in take

    }

    public String take() throws InterruptedException {

        while (buffer.isEmpty()) {

            wait();

        }

        return buffer.remove();

    }

}

在代码中可以看到有两个方法,give 方法负责往 buffer 中添加数据,添加完之后执行 notify 方法来唤醒之前等待的线程,而 take 方法负责检查整个 buffer 是否为空,如果为空就进入等待,如果不为空就取出一个数据,这是典型的生产者消费者的思想。

但是这段代码并没有受 synchronized 保护,于是便有可能发生以下场景:

bash 复制代码
首先,消费者线程调用 take 方法并判断 buffer.isEmpty 方法是否返回 true,若为 true 代表buffer是空的,则线程希望进入等待,但是在线程调用 wait 方法之前,就被调度器暂停了,所以此时还没来得及执行 wait 方法。
此时生产者开始运行,执行了整个 give 方法,它往 buffer 中添加了数据,并执行了 notify 方法,但 notify 并没有任何效果,因为消费者线程的 wait 方法没来得及执行,所以没有线程在等待被唤醒。
此时,刚才被调度器暂停的消费者线程回来继续执行 wait 方法并进入了等待。

虽然刚才消费者判断了 buffer.isEmpty 条件,但真正执行 wait 方法时,之前的 buffer.isEmpty 的结果已经过期了,不再符合最新的场景了,因为这里的"判断-执行"不是一个原子操作,它在中间被打断了,是线程不安全的。

假设这时没有更多的生产者进行生产,消费者便有可能陷入无穷无尽的等待,因为它错过了刚才 give 方法内的 notify 的唤醒。

我们看到正是因为 wait 方法所在的 take 方法没有被 synchronized 保护,所以它的 while 判断和 wait 方法无法构成原子操作,那么此时整个程序就很容易出错。

我们把代码改写成源码注释所要求的被 synchronized 保护的同步代码块的形式,代码如下。

java 复制代码
public void give(String data) {

   synchronized (this) {

      buffer.add(data);

      notify();

  }

}

public String take() throws InterruptedException {

   synchronized (this) {

    while (buffer.isEmpty()) {

         wait();

       }

     return buffer.remove();

  }

}

这样就可以确保 notify 方法永远不会在 buffer.isEmpty 和 wait 方法之间被调用,提升了程序的安全性。

另外,wait 方法会释放 monitor 锁,这也要求我们必须首先进入到 synchronized 内持有这把锁。

这里还存在一个"虚假唤醒"(spurious wakeup)的问题,线程可能在既没有被notify/notifyAll,也没有被中断或者超时的情况下被唤醒,这种唤醒是我们不希望看到的。虽然在实际生产中,虚假唤醒发生的概率很小,但是程序依然需要保证在发生虚假唤醒的时候的正确性,所以就需要采用while循环的结构。

java 复制代码
while (condition does not hold)

    obj.wait();

这样即便被虚假唤醒了,也会再次检查while里面的条件,如果不满足条件,就会继续wait,也就消除了虚假唤醒的风险。 为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?

为什么 wait/notify/notifyAll 方法被定义在 Object 类中?而 sleep 方法定义在 Thread 类中?

主要有两点原因:

  • 因为 Java 中每个对象都有一把称之为 monitor 监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。

  • 因为如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。

wait/notify 和 sleep 方法的异同?

相同点:

它们都可以让线程阻塞。
它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。

不同点:

bash 复制代码
wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。
在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。
sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。
wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。

以上就是关于 wait/notify 与 sleep 的异同点。

相关推荐
程序员-珍5 分钟前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
liuxin3344556623 分钟前
教育技术革新:SpringBoot在线教育系统开发
数据库·spring boot·后端
2401_8572979132 分钟前
招联金融2025校招内推
java·前端·算法·金融·求职招聘
福大大架构师每日一题43 分钟前
23.1 k8s监控中标签relabel的应用和原理
java·容器·kubernetes
金灰1 小时前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5
菜鸟一皓1 小时前
IDEA的lombok插件不生效了?!!
java·ide·intellij-idea
爱上语文1 小时前
Java LeetCode每日一题
java·开发语言·leetcode
bug菌1 小时前
Java GUI编程进阶:多线程与并发处理的实战指南
java·后端·java ee
程序猿小D2 小时前
第二百六十九节 JPA教程 - JPA查询OrderBy两个属性示例
java·开发语言·数据库·windows·jpa
极客先躯2 小时前
高级java每日一道面试题-2024年10月3日-分布式篇-分布式系统中的容错策略都有哪些?
java·分布式·版本控制·共识算法·超时重试·心跳检测·容错策略