Java学习笔记(五):Java多线程(细致入微,持续更新)

Java学习笔记(五):Java多线程(细致入微,持续更新)

文章目录(按需跳转)

  • Java学习笔记(五):Java多线程(细致入微,持续更新)
    • (一)前言
      • 1 - 首先要简单声明一下关于多线程的一些基本认知如下:
        • 使用多线程的优点
      • 2 - 接下来大家可以思考一下,我们什么时候才会需要多线程呢?
        • 关于回调函数
    • (二)线程的创建和使用
      • 1 - JDK中创建线程并使用的四种方式:
        • ①继承Thread类
        • ②实现Runnable接口
        • ③实现Callable接口
        • ④线程池(重要,目前所有互联网项目都用线程池)
          • 1 - 为什么要用线程池呢?
          • 2 - Java线程池的完整构造函数
            • **函数参数说明(此处先过两眼,看过之后的讲解再回头看会理解的更好)**:
          • 3 - 线程池执行流程
          • 4 - 为什么线程池需要使用(阻塞)队列?
          • 5 - 线程池为什么要使用阻塞队列而不使用非阻塞队列?
          • 6 - 如何配置线程池
          • 7 - Java中提供的线程池
            • ① Executors类中提供了四种不同的线程池(实际上Executors类的底层调用就是ThreadPoolExecutor):
            • ② Executors会导致OOM的原因:
            • ③ 所以按照阿里巴巴手册上来说的,应该选择用ThreadPoolExecutor来创建线程池
    • (三)线程的生命周期
      • 1 - 线程的状态
      • 2 - 线程的生命周期
    • (四)Thread类中一些重要的方法(具体用法请参考API文档)
      • 1 - 非静态方法(需要创建对象调用)
      • 2 - 静态方法(Thread类直接调用)
    • (五)线程安全问题
      • 1 - 为什么会出现线程安全问题?
        • 有关错票、重票问题的分析:
          • 1 - 错票
          • 2 - 重票
      • 2 - 解决线程安全问题
        • 方式一:同步代码块synchronized
        • 方式二:同步方法synchronized
        • 方式三:Lock锁
      • 3 - 线程同步的死锁问题
        • 1 - 死锁定义
        • 2 - 关于死锁的例子
        • 3 - 分析死锁产生的原因
          • ① 系统资源的竞争
          • ② 进程运行顺序非法
        • 4 - Java死锁产生的四个必要条件
        • 5 - 死锁Demo
        • 6 - 如何避免死锁?
    • (六)线程通信
      • 1 - 为什么要线程通信?
      • 2 - 线程通信的方法
      • 3 - 线程通信Demo
    • (七)结语
    • (八)参考资料:
    • 最后的最后

(一)前言

1 - 首先要简单声明一下关于多线程的一些基本认知如下:

关于进程、线程、多线程等概念的理解,请看我关于操作系统的个人博客:https://blog.calvinhaynes.top/2021/05/26/cao-zuo-xi-tong-ji-chu-gai-nian-cao-zuo-xi-tong-xi-lie-yi/

使用多线程的优点

​ 1、提高应用程序的响应。(举例:假如上传一首专辑封面的时候,上传原图成功后,才会再生成专辑封面,这段处理也需要程序来完成,如果生成封面的时间成本比较长,单线程的执行效率就不如多线程了,用户的体验也会不好)

​ 2、提高CPU资源的利用率。(依旧是上例,如果处理上传图片操作的服务器是个灭霸级的服务器,何不利用多线程把它榨干呢)

​ 3、改善程序的结构。(单线程的结构需要标记每次处理各个节点的状态,利用多线程的话可以处理完一个销毁一个线程,程序结构更加清晰,比如,将用户的请求放在一个线程中,响应后销毁此线程)

2 - 接下来大家可以思考一下,我们什么时候才会需要多线程呢?

  • 程序需要同时执行两个或者多个任务(某系统需要接受并实现多用户多请求的高并发时)
  • 程序需要后台处理大任务:一个程序是线性执行的,如果程序执行到了某个需要花费很多时间成本的大任务,那主程序就必须等待其完成才能继续执行,此时完全可以开启一个线程把这种大任务放在其中处理,这样后台处理时,主程序也可以继续执行,等到后台处理完后执行回调函数
  • 程序需要处理一个很大的任务,采用多线程并行分片处理

关于回调函数

什么是回调函数?

我们绕点远路来回答这个问题。

编程分为两类:系统编程(system programming)和应用编程(application programming)。所谓系统编程,简单来说,就是编写;而应用编程就是利用写好的各种库来编写具某种功用的程序,也就是应用。系统程序员会给自己写的库留下一些接口,即API(application programming interface,应用编程接口),以供应用程序员使用。所以在抽象层的图示里,库位于应用的底下。

当程序跑起来时,一般情况下,应用程序(application program)会时常通过API调用库里所预先备好的函数。但是有些库函数(library function)却要求应用先传给它一个函数,好在合适的时候调用,以完成目标任务。这个被传入的、后又被调用的函数就称为回调函数(callback function)。

打个比方,有一家旅馆提供叫醒服务,但是要求旅客自己决定叫醒的方法。可以是打客房电话,也可以是派服务员去敲门,睡得死怕耽误事的,还可以要求往自己头上浇盆水。这里,“叫醒”这个行为是旅馆提供的,相当于库函数,但是叫醒的方式是由旅客决定并告诉旅馆的,也就是回调函数。而旅客告诉旅馆怎么叫醒自己的动作,也就是把回调函数传入库函数的动作,称为登记回调函数(to register a callback function)。如下图所示(图片来源:维基百科):

Java学习笔记(五):Java多线程(细致入微,持续更新)_第1张图片

可以看到,回调函数通常和应用处于同一抽象层(因为传入什么样的回调函数是在应用级别决定的)。而回调就成了一个高层调用底层,底层再过头来用高层的过程。(我认为)这应该是回调最早的应用之处,也是其得名如此的原因。

通过上述回调函数的描述,我们可以发现,回调函数和多线程的配合是紧密相关的,流程大致是:创建一个线程,将后台任务加入线程,同时将回调函数(可能是后台任务处理完后的一些操作)作为参数传给线程,此线程执行后台任务完成后执行回调函数。

(二)线程的创建和使用

JVM允许程序运行多个线程,通过java.lang.Thread类体现

Thread类的特性

  • 每个线程都是通过某个特定的Thread对象的run()方法完成操作的,把run()方法的主体称为线程体
  • 通过该Thread对象的start()方法启动此线程,并非直接调用run()方法

1 - JDK中创建线程并使用的四种方式:

①继承Thread类

  1. 定义子类继承Thread类
  2. 子类中重写Thread类中的run()方法
  3. 创建Thread子类对象,即创建一个线程对象
  4. 调用线程对象的start方法:启动线程,调用run()方法
//1.定义子类继承Thread类
class MyThread extends Thread{
	//2.子类中重写Thread类中的run()方法
    @Override
    public void run() {
        //此线程要执行的操作
    }
}

public class ThreadTest{
        public static void main(String[] args) {
            //3.创建Thread子类对象,即创建一个线程对象
            MyThread thread = new MyThread();
            
            //4.调用线程对象的start方法:启动线程,调用run()方法
            thread.start();
        }
	
}

注意事项(关于原因会出单独一篇短文):

  • 不能用run方法启动线程
  • start方法只能调用一次,不可以再用一次start方法创建新的线程(要想创建一个新的线程只能重新创建一个新的对象,再调用start方法)

②实现Runnable接口

  1. 定义子类,实现Runnable接口
  2. 子类中重写Runnable接口中的run方法。
  3. 通过Thread类含参构造器创建线程对象。
  4. 将Runnable接口的子类对象作为实际参数传递给Thread类的构造器中。
  5. 调用Thread类创建对象的start方法:开启线程,调用Runnable子类接口的run方法。
//1.定义子类,实现Runnable接口
class MyThread implements Runnable{
    //2.子类中重写Runnable接口中的run方法。
    @Override
    public void run() {
    	//此线程要执行的操作
    }
    
}

public class ThreadTest{
    public static void main(String[] args) {
        
        //3.通过Thread类含参构造器创建线程对象。
    	MyThread thread = new MyThread();
    
    	//4.将Runnable接口的子类对象作为实际参数传递给Thread类的构造器中。
    	Thread myThread = new Thread(thread);
    
    	//5.调用Thread类创建对象的start方法:开启线程,调用Runnable子类接口的run方法。
    	myThread.start();
    }

}

注意事项(关于原因会出单独一篇短文):

  • 不能用run方法启动线程
  • start方法只能调用一次,不可以再用一次start方法创建新的线程(要想创建一个新的线程只能重新创建一个新的对象,再调用start方法)

③实现Callable接口

  1. 定义子类,实现Callable接口
  2. 子类中重写Callable接口中的call方法。
  3. 创建Callable接口实现的对象
  4. 将此Callable接口实现类的对象作为参数传递到FutureTask的构造器中
  5. 将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象
  6. 调用Thread类创建对象的start方法:开启线程,调用Callable子类接口的call方法。
  7. 如果想要获取call方法的返回值的话,需要调用get()方法
//1、创建一个实现Callable的实现类
class MyThread implements Callable{
     //2、重写实现call()方法,将此线程需要执行的操作声明在call()中
    @Override
    public Object call() throws Exception {
        //此线程要执行的操作
    }
 
}

public class ThreadTest3 {
    public static void main(String[] args) {
        
        //3、创建Callable接口实现的对象
        MyThread numThread = new MyThread();
        
        //4、将此Callable接口实现类的对象作为参数传递到FutureTask的构造器中
        FutureTask task = new FutureTask(numThread);
        
        //5、将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start方法
        new Thread(task).start();
        
        try {
            //6、获取Callable中call方法的返回值
            //get()返回值即为FutureTask构造器参数Callable实现类重写的call()的返回值
            Object retVal = task.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

   

④线程池(重要,目前所有互联网项目都用线程池)

1 - 为什么要用线程池呢?
  1. 降低系统资源消耗,因为在线程池中的线程是可以重复利用的,通过这种重复利用,降低线程创建和销毁造成的消耗(时间和空间)
  2. 提高系统响应速度,每当有任务到达时,通过重复利用线程池中已经存在的线程,无需等待新线程的创建便能立即执行。
  3. 方便线程并发数的管控,如果线程是无限制的创建,可能会导致内存占用过高出现OOM(Out of Memory内存溢出),并且会造成CPU过度切换(CPU切换线程是有TimeCost的,也就是时间成本,因为CPU在切换上下文时需要保持当前执行线程的现场,并且未来还有回复执行线程的现场的TimeCost。
  4. 提供更加强大的管理线程的功能API,比如newScheduledThreadPool顾名思义即可以创建执行延时或者周期性的线程池
2 - Java线程池的完整构造函数
    public ThreadPoolExecutor(int corePoolSize,//线程池核心线程数目
                              int maximumPoolSize,//线程池线程最大数目
                              long keepAliveTime,//线程存活时间
                              TimeUnit unit,//keepAliveTime的时间单位
                              BlockingQueue<Runnable> workQueue,//阻塞任务队列
                              ThreadFactory threadFactory,//线程工厂
                              RejectedExecutionHandler handler)//线程饱和策略
函数参数说明(此处先过两眼,看过之后的讲解再回头看会理解的更好)
  1. corePoolSize(线程池核心线程数目):

    1. 核心线程会一直存活,即使没有任务需要执行
    2. 当向线程池提交一个任务时,如果线程池已经创建的线程数小于corePoolSize,即使此时存在空闲线程,也会重新创建一个新的线程来执行此任务,直到已创建的线程数大于或等于corePoolSize。
  2. maximumPoolSize(线程池线程最大数目):

    1. 当队列满了,并且已经创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。如果队列是无界队列,则忽略此参数。
    2. 当线程数=maximumPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常
  3. keepAliveTime(线程存活时间):

    1. 当线程池中线程数大于corePoolSize时,线程的空闲时间(线程呆着没事儿干的时间)如果超过keepAliveTime,那么这个线程就会被摧毁,直到线程池中的线程数等于corePoolSize
  4. workQueue(阻塞任务队列):

    1. 存放任务的阻塞队列。如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到该队列当中,注意只要超过了 corePoolSize 就会把任务添加到该缓存队列,添加可能成功也可能不成功,如果成功的话就会等待空闲线程去执行该任务,若添加失败(一般是队列已满),就会根据当前线程池的状态决定如何处理该任务(若线程数 < maximumPoolSize 则新建线程;若线程数 >= maximumPoolSize,则会根据拒绝策略做具体处理)。

    2. 常用阻塞队列

      1. ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小;

      2. LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;

      3. synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。

        一般使用后两个,阻塞队列的选取对线程池的影响很大。

  5. ThreadFactory(线程工厂):用来为线程池创建线程,当我们不指定线程工厂时,线程池内部会调用Executors.defaultThreadFactory()创建默认的线程工厂,其后续创建的线程优先级都是Thread.NORM_PRIORITY`。如果我们指定线程工厂,我们可以对产生的线程进行一定的操作。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。

  6. handler(线程饱和策略):拒绝执行策略。当线程池的缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

    1. ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常
    2. ThreadPoolExecutor.DiscardPolicy:丢弃任务但是不抛出异常
    3. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此步骤)
    4. ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务
3 - 线程池执行流程

Java学习笔记(五):Java多线程(细致入微,持续更新)_第2张图片

  • 提交一个任务,线程池里存活的核心线程数小于线程数corePoolSize时,线程池会创建一个核心线程去处理提交的任务。
  • 如果线程池核心线程数已满,即线程数已经等于corePoolSize,一个新提交的任务,会被放进任务队列workQueue排队等待执行。
  • 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列workQueue也满,判断线程数是否达到maximumPoolSize,即最大线程数是否已满,如果没到达,创建一个非核心线程执行提交的任务。
  • 如果当前的线程数达到了maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。
4 - 为什么线程池需要使用(阻塞)队列?
  1. 因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换。
  2. 线程池创建线程需要获取mainlock这个全局锁,影响并发效率,阻塞队列可以很好的缓冲。
  3. 如果新任务的到达速率超过了线程池的处理速率,那么新到来的请求将累加起来,这样的话将耗尽资源。
5 - 线程池为什么要使用阻塞队列而不使用非阻塞队列?

​ 阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。 当队列中有任务时才notify对应线程从队列中取出消息进行执行。 使得在线程不至于一直占用cpu资源。

6 - 如何配置线程池
  1. CPU密集型任务

    ​ 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。

  2. IO密集型任务

    ​ 可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。

  3. 混合型任务

    ​ 根据实际情况考虑。

7 - Java中提供的线程池
① Executors类中提供了四种不同的线程池(实际上Executors类的底层调用就是ThreadPoolExecutor):
//1.newFixedThreadPool:创建一个固定大小的线程池,因为采用无界的阻塞队列,所以实际线程数量永远不会变化,适用于负载较重的场景,对当前线程数量进行限制。(保证线程数可控,不会造成线程过多,导致系统负载更为严重)
public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }


//2.newScheduledThreadPool:适用于执行延时或者周期性任务。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }

/**
     * Creates a new {@code ScheduledThreadPoolExecutor} with the
     * given core pool size.
     *
     * @param corePoolSize the number of threads to keep in the pool, even
     *        if they are idle, unless {@code allowCoreThreadTimeOut} is set
     * @throws IllegalArgumentException if {@code corePoolSize < 0}
     */
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,//此处super指ThreadPoolExecutor
          new DelayedWorkQueue());
}


//3.newSingleThreadExecutor:创建一个单线程的线程池,适用于需要保证顺序执行各个任务。
public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

//4.newCachedThreadPool:用来创建一个可以无限扩大的线程池,适用于负载较轻的场景,执行短期异步任务。(可以使得任务快速得到执行,因为任务时间执行短,可以很快结束,也不会造成cpu过度切换)
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
	}


  • 创建方法:
//1.创建一个固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
//2.创建一个单线程的线程池
ExecutorService threadPool = Executors.newSingleThreadExecutor();
//3.创建一个可以无限扩大的线程池
ExecutorService threadPool = Executors.newCachedThreadPool();
//4.创建一个适用于执行延时或者周期性任务的线程池
ExecutorService threadPool = Executors.newScheduledThreadPool(5);

注意:以上四种方法都不建议使用,可以看下下面这张阿里巴巴Java开发手册的经典截图:

Java学习笔记(五):Java多线程(细致入微,持续更新)_第3张图片

从图片中可以看出不允许使用Executors的根本原因其实只有一个,就是规避资源耗尽,防止OOM,那为什么会导致OOM呢?

② Executors会导致OOM的原因:
  • 首先引入一个极致实例来康康:

    • 在跑以下代码时,要先调整一下JVM的内存,不然你电脑的内存会一直飙升,不要问我为什么,淦!
    • 在IDEA中具体修改位置在Run - > Edit Configurations

    Java学习笔记(五):Java多线程(细致入微,持续更新)_第4张图片

    • 在VM options中填入:-Xms8m -Xmx8m,大致意思就是调整JVM虚拟机的内存大小的,调小一点,这样效果展示更快,机器也不至于跑崩。
      • 关于JVM调优之后博主学习后会有专门的文章讲解:

    Java学习笔记(五):Java多线程(细致入微,持续更新)_第5张图片

    有的小伙伴可能发现你的选项中没有VM options(这也是我傻了,找了好半天的坑),哈哈这个贼蠢,看这里:

    • Modify options,调整展示哪些选项

    Java学习笔记(五):Java多线程(细致入微,持续更新)_第6张图片

    • 点开之后就是以下这个界面,勾选中Add VM options就可以了,之后再遇到其他选项找不到就看看这里,由此可见英语学习和仔细多么重要,所以看到英语不要惧怕,都仔细看一看,不懂的单词查一查,有了这种英语的思维才能更好编程

    Java学习笔记(五):Java多线程(细致入微,持续更新)_第7张图片

package com.calvinhaynes.java;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Created with IntelliJ IDEA.
 * Description:测试Executors会导致的OOM的问题
 * User: CalvinHaynes
 * Date: 2021-05-20
 * Time: 16:38
 */
public class ExecutorsTest {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        while(true){
            executor.submit(new SubThread());
        }
    }
}

class SubThread implements Runnable {
    @Override
    public void run() {
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            //do nothing
        }
    }
}

使用Executors类中的newCachedThreadPool()方法创建无限扩大线程池,然后无限提交任务,过一会儿就会报OOM异常如下图:

内存溢出异常

有关于这种异常产生的原因可以看后面的英文:Java heap space,表示Java堆空间不够,当应用程序申请更多的内存,而Java堆内存已经无法满足应用程序对内存的需要,将抛出这种异常。

也就是说由于线程的不断创建,最终导致内存满了,JVM报出OOM异常。

其他几种方法也会导致不同的OOM异常产生,本文就不细致讲解了,毕竟有点复杂,这样会导致这篇文章过长。

  • 关于OOM异常的细节未来会出文章:
③ 所以按照阿里巴巴手册上来说的,应该选择用ThreadPoolExecutor来创建线程池
  • ThreadPoolExecutor创建线程池Demo(参照之前的参数讲解自行对照):
package com.calvinhaynes.java;


import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * Created with IntelliJ IDEA.
 * Description:测试ThreadPoolExecutor留给用户自行处理的三个方法
 *
 * protected void beforeExecute(Thread t, Runnable r) // 任务执行前被调用
 * protected void afterExecute(Runnable r, Throwable t) // 任务执行后被调用
 * protected void terminated() // 线程池结束后被调用
 *
 * User: CalvinHaynes
 * Date: 2021-05-16
 * Time: 15:11
 */
public class ThreadPoolTest5 {
    public static void main(String[] args) {
        ExecutorService executor = new ThreadPoolExecutor(1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1)) {
            @Override protected void beforeExecute(Thread t, Runnable r) {
                System.out.println("beforeExecute is called");
            }
            @Override protected void afterExecute(Runnable r, Throwable t) {
                System.out.println("afterExecute is called");
            }
            @Override protected void terminated() {
                System.out.println("terminated is called");
            }
        };

        executor.submit(() -> System.out.println("this is a task"));
        executor.shutdown();
    }
}


(三)线程的生命周期

1 - 线程的状态

​ 在Thread类中存在一个枚举类,其中说明了JVM中线程的五种状态。

public enum State {
        /**
         * Thread state for a thread which has not yet started.
         */
        NEW,

        /**
         * Thread state for a runnable thread.  A thread in the runnable
         * state is executing in the Java virtual machine but it may
         * be waiting for other resources from the operating system
         * such as processor.
         */
        RUNNABLE,

        /**
         * Thread state for a thread blocked waiting for a monitor lock.
         * A thread in the blocked state is waiting for a monitor lock
         * to enter a synchronized block/method or
         * reenter a synchronized block/method after calling
         * {@link Object#wait() Object.wait}.
         */
        BLOCKED,

        /**
         * Thread state for a waiting thread.
         * A thread is in the waiting state due to calling one of the
         * following methods:
         * 
    *
  • {@link Object#wait() Object.wait} with no timeout
  • *
  • {@link #join() Thread.join} with no timeout
  • *
  • {@link LockSupport#park() LockSupport.park}
  • *
* *

A thread in the waiting state is waiting for another thread to * perform a particular action. * * For example, a thread that has called Object.wait() * on an object is waiting for another thread to call * Object.notify() or Object.notifyAll() on * that object. A thread that has called Thread.join() * is waiting for a specified thread to terminate. */ WAITING, /** * Thread state for a waiting thread with a specified waiting time. * A thread is in the timed waiting state due to calling one of * the following methods with a specified positive waiting time: *

    *
  • {@link #sleep Thread.sleep}
  • *
  • {@link Object#wait(long) Object.wait} with timeout
  • *
  • {@link #join(long) Thread.join} with timeout
  • *
  • {@link LockSupport#parkNanos LockSupport.parkNanos}
  • *
  • {@link LockSupport#parkUntil LockSupport.parkUntil}
  • *
*/
TIMED_WAITING, /** * Thread state for a terminated thread. * The thread has completed execution. */ TERMINATED; }
  1. NEW:新建状态,当一个Thread类或其子类的对象被声明并创建的时候,新的线程对象处于新建状态。
  2. RUNNABLE:就绪状态,当处于新建状态的线程调用其start()方法后,将进入线程队列等待CPU时间片,此时它已经具备运行的条件,只是未分配到CPU资源。
  3. Running:运行状态,就绪的线程被调度获得CPU资源时,进入运行状态,run()方法中定义了线程的操作和功能。
  4. BLOCKED:阻塞状态,在某种特殊情况下,被认为挂起或执行输入输出操作时,让出CPU并临时中止自己的执行,进入阻塞状态。
  5. TERMINATED:死亡状态,线程完成了它的全部工作或线程被提前强制性的中止或出现异常倒置导致结束。

WAITING,TIMED_WAITING:在JVM中线程的两种等待状态。

2 - 线程的生命周期

Java学习笔记(五):Java多线程(细致入微,持续更新)_第8张图片

流程分析:

  1. new一个新线程对象后,该线程对象就处于新建状态。
  2. 调用该对象的start()方法,该线程就进入了就绪状态。
  3. 就绪状态获得了CPU资源,进入了运行状态
  4. 在运行状态中如果执行了sleep()睡眠方法、suspend()挂起方法、wait()等待方法、join()等待方法、等待同步监视器(这些后文会提到),则会进入阻塞状态
  5. 同样的,调用与上述方式相对立的方式(图片中)即可离开阻塞状态,继续回到就绪状态等待CPU调度
  6. 当运行状态执行完线程中的run()方法,或调用stop()方法、出现错误或异常未处理,则线程死亡

(四)Thread类中一些重要的方法(具体用法请参考API文档)

建议每一个方法可以自己依次敲一下试一下

1 - 非静态方法(需要创建对象调用)

  1. public void start()
    使该线程开始执行;Java 虚拟机调用该线程的 run 方法。

  2. public void run()
    如果该线程是使用独立的 Runnable 运行对象构造的,则调用该 Runnable 对象的 run 方法;否则,该方法不执行任何操作并返回。

  3. public final void setName(String name)
    改变线程名称,使之与参数 name 相同。

  4. public final void getName(String name)
    获取线程名称。

  5. public final void setPriority(int priority) 更改线程的优先级。

  6. public final void setDaemon(boolean on)
    将该线程标记为守护线程或用户线程。

  7. public final void join(long millisec)
    在线程a中调用线程b的join(), 此时线程a进入阻塞状态, 直到线程b完全执行完以后, 线程a才结束阻塞状态,等待该线程终止的时间最长为 millisec 毫秒。

  8. public final void stop()

    当执行此方法时,强制结束当前线程.

  9. public void interrupt()
    中断线程。

  10. public final boolean isAlive()
    测试线程是否处于活动状态。

2 - 静态方法(Thread类直接调用)

  1. public static void yield() 释放当前CPU的执行权,暂停当前正在执行的线程对象,并执行其他线程。

  2. public static void sleep(long millisec)
    在指定的毫秒数内让当前正在执行的线程休眠(阻塞状态),此操作受到系统计时器和调度程序精度和准确性的影响。

  3. public static native Thread currentThread()

    返回当前代码执行的线程。

    这是一个native方法,native关键字是用于Java和其他语言(C++)协作时用的,也就是只native关键字修饰的函数不是用Java写的,所以实际上调用的是jvm.cpp中的JVM_CurrentThread函数,具体细节内容未来深究。

(五)线程安全问题

1 - 为什么会出现线程安全问题?

  1. 多线程执行的不确定性,导致线程之间的交互,一会儿执行线程A,一会儿执行线程B
  2. 多线程操作共享数据的时候,会破坏共享数据

一个典型的线程安全问题,卖票问题由于多线程导致发生错票重票

/** * Created with IntelliJ IDEA. * Description: *  例子:创建三个窗口卖票,总票数为100张.使用继承Thread类的方式 * *  目前存在线程安全问题,待解决 * User: CalvinHaynes * Date: 2021-04-19 * Time: 21:11 */class Window extends Thread{    private static int ticket = 100;    @Override    public void run() {        while(true){            if(ticket > 0){                System.out.println(Thread.currentThread().getName() + ":" + "卖票咯,票号为:" + ticket);                ticket--;            }else{                break;            }        }    }}public class WindowTest {    public static void main(String[] args) {        Window window1 = new Window();        Window window2 = new Window();        Window window3 = new Window();        window1.start();        window2.start();        window3.start();    }}

Java学习笔记(五):Java多线程(细致入微,持续更新)_第9张图片

有关错票、重票问题的分析:

1 - 错票

​ 如果window1线程执行过程中进入阻塞状态,这时其他两个线程操作ticket就会导致错票的现象。

极端状态:同时有两个线程被阻塞,则会导致多卖两张票

Java学习笔记(五):Java多线程(细致入微,持续更新)_第10张图片

2 - 重票

​ 如果window1线程在输出票号和ticket减一的操作之间进入阻塞状态,则会导致另一个进程在输出票号时输出一个相同的票号,即发生了重票现象。

2 - 解决线程安全问题

解决线程安全问题有三种方式:同步代码块、同步方法、Lock锁

方式一:同步代码块synchronized

synchronized(同步监视器){需要被同步的代码}

说明

  1. 操作共享数据的代码,即为需要被同步的代码。 -->不能包含代码多了,也不能包含代码少了。

  2. 共享数据:多个线程共同操作的变量。比如:ticket就是共享数据。

    1. 只有共享数据的读写访问才需要同步机制,非共享资源没有同步的必要
    2. 只有当共享资源是可变的的时候,才需要同步机制,否则没有同步的必要
  3. 同步监视器,俗称:锁。任何一个类的对象,都可以充当锁。

    要求:多个线程必须要共用同一把锁。(即同步锁也是多个线程的共享对象)

锁的选择

  1. 自己创建一个对象,比如Object对象
  2. 使用this表示当前类的对象
    1. 继承Thread类的创建线程的方法不可以使用this作为锁,原因是继承thread实现多线程时,会创建多个子类对象来代表多线程,这时this所指的当前类的对象不是唯一的,不能当做锁
    2. 实现Runnable接口的创建线程的方法可以使用this当做锁,因为此种方式只需要创建一个对象,再将此对象作为参数传入多个Thread对象就可以当做多个线程,this是唯一的
    3. 使用类当做锁,比如synchronized(Window3.class),不妨得出一个结论,类也是一个对象(反射)

补充:在实现Runnable接口创建多线程的方式中,我们可以考虑使用this充当同步监视器。(前提是当前类是唯一类)

class Window3 implements Runnable{
    private int ticket = 100;

//    任意对象都可以当锁
//    Object obj = new Object();
//    Dog dog = new Dog();

    @Override
    public void run() {
//        Object obj = new Object();//这相当于三个对象(多个线程必须同一把锁)
        while(true){
            synchronized(this){//当前的唯一Windows3对象作为同步监视器
                if(ticket > 0){
                    System.out.println(Thread.currentThread().getName() + ":" + "卖票咯,票号为:" + ticket);
                    ticket--;
                }else{
                    break;
                }
            }
        }
    }
}

class Dog{

}
public class WindowTest3 {
    public static void main(String[] args) {
        Window3 window3 = new Window3();

        Thread th1 = new Thread(window3);
        Thread th2 = new Thread(window3);
        Thread th3 = new Thread(window3);

        th1.setName("窗口一");
        th2.setName("窗口二");
        th3.setName("窗口三");

        th1.start();
        th2.start();
        th3.start();
    }
}

方式二:同步方法synchronized

​ 将要同步的代码放到一个方法中,再将方法声明为synchronized同步方法,在run()方法中调用此同步方法。

说明

  1. 同步方法依然需要同步监视器,只不过不需要我们显式的声明了
  2. 非静态的同步方法,同步监视器是:this
  3. 静态的同步方法,同步监视器是:当前类本身
/**
 * Created with IntelliJ IDEA.
 * Description:使用同步方法解决实现Runnable接口的窗口安全问题
 *
 *  关于同步方法的总结:
 *  1. 同步方法仍然涉及到同步监视器,只是不需要我们显式的声明。
 *  2. 非静态的同步方法,同步监视器是:this
 *     静态的同步方法,同步监视器是:当前类本身
 * User: CalvinHaynes
 * Date: 2021-04-22
 * Time: 10:19
 */

class Window5 implements Runnable{

    private int ticket = 100;

    @Override
    public void run() {
        while(true){
            show();
        }
    }

    private synchronized void show(){//同步监视器:this(隐式定义)

//        synchronized (this) {
            if (ticket > 0) {

                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);

                ticket--;
            }
//        }
    }
}
public class WindowTest5 {
    public static void main(String[] args) {
        Window5 window5 = new Window5();

        Thread t5_1 = new Thread(window5);
        Thread t5_2 = new Thread(window5);
        Thread t5_3 = new Thread(window5);

        t5_1.setName("窗口一");
        t5_2.setName("窗口二");
        t5_3.setName("窗口三");

        t5_1.start();
        t5_2.start();
        t5_3.start();
    }


}

方式三:Lock锁

​ JDK5.0之后,可以通过实例化ReentrantLock对象,在所需要同步的语句前,调用ReentrantLock对象的lock()方法,实现同步锁,在同步语句结束时,调用unlock()方法结束同步锁

synchronized和lock的异同:

  • Lcok是显式锁(需要手动开启和关闭锁),synchronized是隐式锁,出作用域自动释放。
  • Lock只有代码块锁,synchronized有代码块锁和方法锁。
  • 使用Lcok锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的拓展性(提供更多的子类)
class WindowLock implements Runnable{

    private int ticket = 100;

    //1.实例化ReentrantLock
    private ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
        while(true){
            try {

                //2.调用锁定方法
                lock.lock();

                if(ticket > 0){
                    System.out.println(Thread.currentThread().getName() + ":售票,票号为:" + ticket);
                    ticket--;
                }else{
                    break;
                }
            } finally {
                //3.调用解锁方法
                lock.unlock();
            }
        }
    }
}
public class LockTest {
    public static void main(String[] args) {
        WindowLock windowLock = new WindowLock();

        Thread l1 = new Thread(windowLock);
        Thread l2 = new Thread(windowLock);
        Thread l3 = new Thread(windowLock);

        l1.setName("窗口一");
        l2.setName("窗口二");
        l3.setName("窗口三");

        l1.start();
        l2.start();
        l3.start();
    }


}

3 - 线程同步的死锁问题

1 - 死锁定义

​ 死锁即是指多个线程因资源竞争而造成的一种僵局(deadlock),多个线程互相等待,彼此都处于阻塞状态,都无法继续前进。

2 - 关于死锁的例子

​ 某计算机系统中有一台打印机和一台输入设备,进程Process1正在占用打印机,但同时又提出使用输入设备的请求,但是此时输入设备正在被Process2进程占用,而Process2在未释放输入设备之前,又提出使用打印机的请求,这样就会导致Process1和Process2互相无休止的等待,此时两个进程进入了死锁状态。

3 - 分析死锁产生的原因

① 系统资源的竞争

​ 通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可剥夺资源的竞争才可能产生死锁,对可剥夺资源的竞争是不会引起死锁的。

② 进程运行顺序非法

​ 进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都会因为所需资源被占用而阻塞。

​ Java中死锁最简单的情况是,一个线程T1持有锁L1并且申请获得锁L2,而另一个线程T2持有锁L2并且申请获得锁L1,因为默认的锁申请操作都是阻塞的,所以线程T1和T2永远被阻塞了。导致了死锁。这是最容易理解也是最简单的死锁的形式。但是实际环境中的死锁往往比这个复杂的多。可能会有多个线程形成了一个死锁的环路,比如:线程T1持有锁L1并且申请获得锁L2,而线程T2持有锁L2并且申请获得锁L3,而线程T3持有锁L3并且申请获得锁L1,这样导致了一个锁依赖的环路:T1依赖T2的锁L2,T2依赖T3的锁L3,而T3依赖T1的锁L1。从而导致了死锁。

​ 综上,产生死锁的根本原因应该是:

​ 1.线程1在获得锁1时又去申请了锁2,在未释放自己的锁的情况下去申请另外一把锁。

​ 2.默认锁申请的操作时阻塞的

4 - Java死锁产生的四个必要条件

  1. 互斥使用:当资源被一个线程占有时,其他线程不能使用(即有锁)
  2. 不可抢占:资源请求者不能强制从资源占用者处夺走资源,只能等待资源占用者主动释放
  3. 请求和等待:资源请求者在申请其他资源时保持对自己资源的占有
  4. 循环等待:复杂的死锁问题,形成一个等待队列:(P1,P2,P3,…)中每一个进程已获得的资源同时被下一个进程所请求。 (即资源分配图中含圈,此处涉及操作系统的知识)

5 - 死锁Demo

public class ThreadTest {
    public static void main(String[] args) {

        StringBuffer s1 = new StringBuffer();
        StringBuffer s2 = new StringBuffer();

        //匿名对象1
        new Thread(){
            @Override
            public void run() {
                synchronized (s1){

                    s1.append("a");
                    s2.append("1");

                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    synchronized (s2){

                        s1.append("b");
                        s2.append("2");

                        System.out.println(s1);
                        System.out.println(s2);
                    }
                }
            }
        }.start();

        //匿名对象2
        new Thread(){
            @Override
            public void run() {
                synchronized (s2){

                    s1.append("c");
                    s2.append("3");

                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    synchronized (s1){
                        s1.append("d");
                        s2.append("4");

                        System.out.println(s1);
                        System.out.println(s2);
                    }
                }
            }
        }.start();

    }
}

由以上实例可以看出在匿名线程对象一中占有着锁s1,同时申请锁s2,在匿名线程对象二中占有着锁s2,同时申请锁s1,形成死锁。

6 - 如何避免死锁?

  1. 注意加锁顺序:线程一定要按照一定的顺序加锁,注意好各个锁的连带关系做一个排序,排序靠后的锁一定要等到排序靠前的锁释放之后才能加锁(避免嵌套锁

  2. 加锁时限:线程尝试获取锁的时候加上时间限制,超过设置的时限则放弃请求,并且释放自己占有的锁

  3. 死锁检测

(六)线程通信

1 - 为什么要线程通信?

  1. 多线程并发的时候,默认CPU是随机进行线程切换的,如果我们需要多个线程来共同完成一项任务,并希望他们有顺序规矩的执行,那么就要进行多线程之间的通信,达到多线程共同操作一份数据而按部就班的效果。
  2. 正常情况下如果多线程共同操作一份数据,必然存在争夺的情况,为了避免争夺,通过引入wait(),notify()机制使得多线程工作起来更加和谐。

2 - 线程通信的方法

  • wait()

    • 线程调用wait()方法后,会释放该线程的锁,然后等待另外线程来notify()/notifyAll()它,这样才能从新获得锁的所有权并恢复执行
    • 确保调用wait()方法的时候该线程拥有锁,即,wait()方法调用时必须放在synchronized方法或synchronized块中。
  • notify()/notifyAll()

    • 这两种方法都定义在类:Object中

      • notify()方法会唤醒一个等待当前对象的锁的线程。唤醒在此对象监视器上等待的单个线程。
      • notifyAll()方法会唤醒在此对象监视器上等待的所有线程。

3 - 线程通信Demo

//线程通信例子:使用两个线程交替打印1~100
class Number implements Runnable{

    private int number;

    @Override
    public void run() {
        while(true){
            synchronized (this) {
                this.notify();

                if(number <= 100){

                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    System.out.println(Thread.currentThread().getName() + ":" + number);
                    number++;
                }else{
                    break;
                }

                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
public class CommunicationTest {
    public static void main(String[] args) {
        Number number = new Number();

        Thread thread1 = new Thread(number);
        Thread thread2 = new Thread(number);

        thread1.setName("线程一");
        thread2.setName("线程二");

        thread1.start();
        thread2.start();
    }

}

(七)结语

Java多线程是一门比较深的学问,如果之前学过操作系统基础课程的话,会学的更透彻一点,因为博主也刚刚接触操作系统,所以可能有些地方说的也欠妥,希望大佬们可以指正,互相交流学习。

重在理解这个过程,代码层面重点学习线程池,目前也是互联网企业常用的方式。

(八)参考资料:

  • Java编程思想(第四版)

  • https://cloud.tencent.com/developer/article/1638175

  • https://www.bilibili.com/video/BV1Kb411W75N?t=8&p=528

  • https://www.zhihu.com/question/19801131/answer/27459821

最后的最后

我是一个热爱IT技术和音乐的Dream Catcher,正在努力培养计算机的深度和广度认知,也会和大家伙儿分享我的音乐,大家伙儿多多关照 (๑❛ᴗ❛๑)
联系我的话,可以邮箱或者私信哦!!谢谢大家咯(*≧▽≦)
My Social Link:
我的个人博客站:https://blog.calvinhaynes.top/
我的知乎主页:https://www.zhihu.com/people/eternally-92-61
我的B站主页:https://space.bilibili.com/434604897
我的CSDN主页:https://blog.csdn.net/qq_45772333
我的邮箱:[email protected]
我的Github主页:https://github.com/CalvinHaynes
我的码云主页:https://gitee.com/CalvinHaynes

喜欢我的文章的话,不妨留下你的大拇指,点个赞再走,您的支持是我创作的最大动力,也欢迎指正博客中存在的问题,谢谢呐(~ ̄▽ ̄)~

你可能感兴趣的:(Java,java,编程语言,多线程,面试,intellij,idea)