三、Java 多线程_08(笔记)

Java 多线程

Java 给多线程编程提供了内置的支持。

一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

多线程是多任务的一种特别的形式,但多线程使用了更小的资源开销。多线程能满足程序员编写高效率的程序来达到充分利用 CPU 的目的。

一、进程和线程

首先看一组非常传神的图例,相信可以帮助你更好理解进程与线程的概念:

在这里插入图片描述

1、什么是进程

进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,包含一个或多个线程。一个线程不能独立的存在,它必须是进程的一部分。一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。进程有如下的一些特征:

  1. 进程依赖于程序运行而存在,进程是动态的,程序是静态的。

  2. 进程是操作系统进行资源分配和调度的一个独立单位(CPU除外,线程是处理器任务调度和执行的基本单位)。

  3. 每个进程拥有独立的地址空间,地址空间包括代码区、数据区和堆栈区,进程之间的地址空间是隔离的,互不影响

  4. 多进程是指操作系统能同时运行多个任务(程序)

    进程:是正在运行的程序
    是系统进行资源分配和调用的独立单位
    每一个进程都有它自己的内存空间和系统资源
    

2、什么是线程

同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),线程切换开销小。

在这里插入图片描述

线程:是进程中的单个顺序控制流,是一条执行路径
单线程:一个进程如果只有一条执行路径,则称为单线程程序
多线程:一个进程如果有多条执行路径,则称为多线程程序

3、区别

  1. 本质区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。

  2. 包含关系:一个进程至少有一个线程,线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

  3. 资源开销:每个进程都有独立的地址空间,进程之间的切换会有较大的开销;线程可以看做轻量级的进程,同一个进程内的线程共享进程的地址空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小

  4. 影响关系:一个进程崩溃后,在保护模式下其他进程不会被影响,但是一个线程崩溃可能导致整个进程被操作系统杀掉,所以多进程要比多线程健壮

在这里插入图片描述

理论过于抽象难解,下面还是用大家喜闻乐见的现实中的例子去类比,没错还是工厂的例子:

在这里插入图片描述

在计算机这个大工厂中,进程被比作一个车间,为生产活动提供了设计图、场地、生产线(线程)等生产要素,而线程是这个车间中的一条条生产线。生产线本身会有一个操作台,具体的零件在这里被生产。生产线必须由工人操作才能动起来,当工人来到一个生产线旁并启动它之前,必须查阅生产线的生产记录以便弄清楚这个生产线的零件加工到哪种程度了,然后才能准确地接续生产,当工人停止生产线前也必须记录这次的生产进度以备下次读取,这些进度信息可以理解为上下文,读取和记录生产进度的过程称为上下文切换。

一个工人可以在多条生产线间穿梭操作,就像 CPU 在不同线程间切换一样,这个动作被称为并发,与之对应的,多个工人操作多条生产线同时生产,称为并行。如果生产线不需要太多原料输入就能生产,那这种生产任务被称作 CPU 密集型,反之如果生产线大部分时间在等待原料的输入,那这种任务被称为IO 密集型。显然,前者最好一条生产线由一个工人专管效率更高,而后一种任务,一个人在原料输入的间隙去操作其他生产线,无疑能提高工人利用率。

二、并行和并发

并行:多个 cpu 实例或者多台机器同时执行一段处理逻辑,是真正的同时。(例如:银行的多个窗口同时运行)

并发:通过 cpu 调度算法,让用户看上去同时执行,实际上从 cpu 操作层面不是真正的同时。并发往往在场景中有公用的资源,那么针对这个公用的资源往往产生瓶颈,我们会用 TPS 或者 QPS 来反应这个系统的处理能力。

在这里插入图片描述

线程的生命周期

线程是一个动态的执行过程,它也有从产生到死亡的过程。

在这里插入图片描述

1、新建状态

使用 new 关键字产生 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。

2、就绪状态

当线程对象调用了 start() 方法之后,该线程就进入就绪状态,该状态的线程位于可运行线程池中,变得可运行,等待获取 CPU 的使用权。就绪状态的线程处于就绪队列中,要等待 JVM 里线程调度器的调度。

3、运行状态

如果就绪状态的线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。

4、阻塞状态

如果一个线程执行了 sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。可以分为三种:

  1. 等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态。
  2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池中。
  3. 其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当 sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态。

5、死亡状态

一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。

线程的实现

Java 是支持多线程编程的,在 Java 中有四种实现方式:

  • 继承 Thread 类
  • 实现 Runnable 接口
  • 实现 Callable 接口
  • 线程池方式

其中线程池方法会在后面专门讲解,这里只说到前三种。

1、继承 Thread 类

这种方式是非常基本的写法,只需要类继承 Thread 类,并实现 run 方法即可。

public class zhou extends Thread {
 
    public void run(){
        // 这里是线程要执行的代码块
    }
}

调用时,也比较简单,产生当前类对象,并执行 start() 方法。

zhou zh= new zhou();
zh.start();

2、实现 Runnable 接口

这种方式比较常用,实现 Runnable 接口,并重写 run 方法。

public class zhou implements Runnable {
 
    public void run(){
        // 这里是线程要执行的代码块
    }
}

调用时要多一个步骤,因为 Runnable 接口的实现类对象并不是线程对象,可以理解成一个‘任务’。

zhou zh= new zhou();
Thread t = new Thread(zh);
t.start();

3、通过 Callable 创建线程

通过这种方式,线程可以返回结果。但是,这种方式的步骤较为繁琐:

  1. 创建 Callable 接口的实现类,并实现 call()方法,该 call()方法将作为线程执行体,并且有返回值(类似于 run()方法)。
  2. 创建 Callable 实现类的实例,使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call()方法的返回值。
  3. 使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
  4. 调用 FutureTask 对象的 get()方法来获得子线程执行结束后的返回值。
public class zhou implements Callable<T>{
 
    public T call(){
        // 这里是线程要执行的代码块
    }
}

调用时的代码:

zhou zh= new zhou();
FutureTask<String> ft = new FutureTask<>(zh);
Thread t = new Thread(ft);
t.start();
String result = ft.get();

4、Runnable 和 Callable 的区别

  • Runnable 没有返回值,而实现 Callable 接口的任务线程能返回执行结果。
  • Callable 接口实现类中的 call 方法允许异常向上抛出,但是 Runnable 接口实现类中 run 方法的异常必须在内部处理,不能抛出。

5、run() 和 start() 之间的区别?

只有调用了 start() 方法,才会表现出多线程的特性,不同线程的 run() 方法里面的代码交替执行。如果只是调用 run() 方法,那么代码还是同步执行的,必须等待一个线程的 run() 方法里面的代码全部执行完毕之后,另外一个线程才可以执行其 run() 方法里面的代码。

线程的优先级

每一个 Java 线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。具有较高优先级的线程对程序更重要,并且应该在低优先级的线程之前分配处理器资源。但是,线程优先级不能保证线程执行的顺序,而且非常依赖于平台。

在 Java 中,可以通过 setPriority(int newPriority) 方法来设置线程的优先级。

Java 线程的优先级是一个整数,线程的优先级分为 1~10 一共 10 个等级,所有线程默认优先级为 5,如果优先级小于 1 或大于 10,则会抛出 java.lang.IllegalArgumentException 异常。

Java 提供了 3 个常量值可以用来定义优先级,源码如下:

public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;

线程同步

即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多,临界区对象就是其中一种。

在一般情况下,创建一个线程是不能提高程序的执行效率的,所以要创建多个线程。但是多个线程同时运行的时候可能调用线程函数,在多个线程同时对同一个内存地址进行写入,由于 CPU 时间调度上的问题,写入数据会被多次的覆盖,所以就要使线程同步。

同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。

“同”字从字面上容易理解为一起动作,其实不是,“同”字应是指协同、协助、互相配合。

如进程、线程同步,可理解为进程或线程A和B一块配合,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行;B依言执行,再将结果给A;A再继续操作。

所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,同时其它线程也不能调用这个方法。

在多线程编程里面,一些敏感数据不允许被多个线程同时访问,此时就使用同步访问技术,保证数据在任何时刻,最多有一个线程访问,以保证数据的完整性。

线程同步的方式

  • 同步方法和同步块—synchronized
  • 显示锁—Lock
  • ThreadLocal 实现线程同步

由于我们可以通过 private 关键字来保证数据对象只能被方法访问,所以我们只需要针对方法提出一套机制,这套机制就是 synchronized 关键字,它包括两种用法 synchronized 方法和 synchronized 块。

同步方法

public synchronized void method( int args){}

synchronized 方法控制对“对象”的访问,每个对象对应一把锁,每个 synchronized 方法都必须获得调用该方法的对象的锁才能执行,否则线程会阻塞,方法一旦执行,就独占该锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行。

同步块

synchronized (obj){}

若将一个大的方法声明为 synchronized 将会影响代码运行的效率,这时候我们就可以使用同步代码块这种方式。

obj 可以是任何对象,但是推荐使用共享资源作为同步监视器。

同步方法和同步块的代码

public class Main {
    class ThreadDemo extends Thread {
        private SynchronizedDemo sd ;
        public ThreadDemo(SynchronizedDemo sd) {
            this.sd = sd;
        }
        public void run() {
            for (int i = 0; i < 10; i++) {
                sd.setAccount(1);
            }
        }
    }
    class SynchronizedDemo {
        private Integer account = 100;
        public synchronized void setAccount(int account) {
            this.account += account;
        }
        public void setAccount(int account) {
            synchronized(this){
                this.account += account;
            }
        }
    }
    public static void main(String[] args) {
        SynchronizedDemo sd = new SynchronizedDemo();
        ThreadDemo t1 = new ThreadDemo(sd);
        ThreadDemo t2 = new ThreadDemo(sd);
        t1.start();
        t2.start();
    }
}

显示锁

一般我们都用Lock的实现类:ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁,它与使用 synchronized 方法具有相同的基本行为和语义,并且扩展了其能力。

public class Main {
    class LockDemo {
        private Integer account = 0;
        private ReentrantLock lock = new ReentrantLock();
        public void setAccount(int account){
            // 上锁
            lock.lock();
            this.account += account;
            // 解锁
            lock.unlock();
        }
    }
    class ThreadDemo extends Thread {
        private LockDemo ld ;
        public ThreadDemo(LockDemo ld) {
            this.ld = ld;
        }
        public void run() {
            for (int i = 0; i < 10; i++) {
                ld.setAccount(1);
            }
        }
    }
    public static void main(String[] args) {
        LockDemo ld = new LockDemo();
        ThreadDemo t1 = new ThreadDemo(ld);
        ThreadDemo t2 = new ThreadDemo(ld);
        t1.start();
        t2.start();
    }
}

两种锁的区别

  1. Lock是显式锁(手动开启和关闭锁,别忘记关闭锁) ;synchronized是隐式锁,出了作用域自动释放。
  2. Lock只有代码块锁,syηchronized有代码块锁和方法锁。
  3. 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)。
  4. 优先使用顺序:Lock>同步代码块 > 同步方法。

ThreadLocal 实现线程同步

如果使用 ThreadLocal 管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响,从而实现线程同步。

public class Main {
    class ThreadLocalDemo {
        private ThreadLocal<Integer> account = ThreadLocal.withInitial(() -> 1);
        public void setAccount(int account){
            this.account.set(this.account.get()+account);
        }
        public int getAccount(){
            return account.get();
        }
    }
    class ThreadDemo extends Thread {
        private ThreadLocalDemo tld;
        public ThreadDemo(ThreadLocalDemo tld) {
            this.tld = tld;
        }
        public void run() {
            for (int i = 0; i < 10; i++) {
                tld.setAccount(1);
            }
        }
    }
    public static void main(String[] args) {
        ThreadLocalDemo tld = new ThreadLocalDemo();
        ThreadDemo t1 = new ThreadDemo(tld);
        ThreadDemo t2 = new ThreadDemo(tld);
        t1.start();
        t2.start();
    }
}

线程安全

如果你的代码在多线程下和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。

  • **不可变:**像 String、Integer、Long 这些,都是 final 类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用。
  • **绝对线程安全:**不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,Java 中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,Java 中也有,比方说 CopyOnWriteArrayList、CopyOnWriteArraySet。
  • **相对线程安全:**相对线程安全也就是我们通常意义上所说的线程安全,像 Vector 这种,add、remove 方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个 Vector、有个线程同时在 add 这个 Vector,99% 的情况下都会出现 ConcurrentModificationException,也就是 fail-fast 机制。
  • **线程非安全:**这个就没什么好说的了,ArrayList、LinkedList、HashMap等都是线程非安全的类。

我们可以通过线程同步的方式来保证线程安全。

CopyOnWriteArrayList介绍
它相当于线程安全的ArrayList。和ArrayList一样,它是个可变数组;但是和ArrayList不同的时,它具有以下特性:

  1. 它最适合于具有以下特征的应用程序:List 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。
  2. 它是线程安全的。
  3. 因为通常需要复制整个基础数组,所以可变操作(add()、set() 和 remove() 等等)的开销很大。
  4. 迭代器支持hasNext(), next()等不可变操作,但不支持可变 remove()等操作。
  5. 使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。

线程调度和通信

Object 类中的 wait、notify、notifyAll,可以用于线程间的通信,通过这三个方法完成线程在指定锁(监视器)上的等待与唤醒,这三个方法是以锁为中心的通信方法。

除了它们之外,还有用于线程调度、控制的方法,他们分别是 sleep、yield 和 join 方法,他们可以用于线程的协作,他们是围绕着线程的调度而来的。

sleep 方法

sleep 方法是属于 Thread 类中的,sleep 过程中线程不会释放锁,只会阻塞线程,让出 cpu 给其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态,可中断,sleep 给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会。总结下来有如下几点:

  1. 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行)。
  2. sleep 不会释放锁,可以理解为他进入监视器这个房间之后,在这房间里面睡着了。
  3. sleep 方法用 static 所修饰,既然是静态方法,在Thread中的惯例就是针对于:当前线程。
  4. 与wait类似的是,sleep 也是可中断方法(从方法签名可以看得出来,其可能抛出InterruptedException),也就是说如果一个线程正在 sleep,如果另外的线程将他中断(调用interrupt方法),将会抛出异常,所以对于sleep方法,要么自己醒来,要么被中断后也会醒来。
public class ThreadSleepTest {
    private static final Object obj = new Object();
 
    public static void main(String[] args) {
        Thread t1 = new Thread(new Thread1());
        Thread t2 = new Thread(new Thread2());
        t1.start();
        Thread.sleep(500);
        t2.start();
    }
 
    class Thread1 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread1 start");
                Thread.sleep(5000);
                System.out.println("thread1 end");
            }
        }
    }
 
    class Thread2 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread2 start");
                System.out.println("thread2 end");
            }
        }
    }
}

wait 方法

wait 方法是属于 Object 类中的一个方法,wait 方法在执行过程中线程会释放对象锁,只有当其他线程调用 notify 方法后才能唤醒此线程。wait 使用时必须先获取对象锁,即必须在 synchronized 修饰的代码块中使用,那么相应的 notify 方法同样必须在 synchronized 修饰的代码块中使用,如果没有在synchronized 修饰的代码块中使用时运行时会抛出 IllegalMonitorStateException 的异常。

public class ObjectWaitTest {
    private static final Object obj = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(new Thread1());
        Thread t2 = new Thread(new Thread2());
        t1.start();
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        t2.start();
    }
    class Thread1 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread1 start");
                try {
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("thread1 end");
            }
        }
    }
    class Thread2 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread2 start");
                obj.notify();
                System.out.println("thread2 end");
            }
        }
    }
}

yield 方法

和 sleep 一样都是 Thread 类的静态方法,都是暂停当前正在执行的线程对象不会释放资源锁,和 sleep 不同的是 yield 方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取 CPU 执行时间,所以执行 yield() 的线程有可能在进入到可执行状态后马上又被执行。还有一点和 sleep 不同的是 yield 方法只能使同优先级或更高优先级的线程有执行的机会。

yield 方法将会暂停当前正在执行的线程对象,并执行其它线程,他始终都是 RUNNABLE 状态,不过要注意,yield 只是一种建议性的,如果调用了 yield 方法,对 CPU 时间片的分配进行了“礼让”,它仍旧有可能继续获得时间片,并且继续执行,所以一次调用 yield 并不一定肯定会发生什么。

public class ThreadYieldTest {
    private static final Object obj = new Object();
 
    public static void main(String[] args) {
        Thread t3 = new Thread(new Thread3());
        Thread t1 = new Thread(new Thread1());
        Thread t2 = new Thread(new Thread2());
        t3.start();
        t1.start();
        t2.start();
    }
 
    class Thread1 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread1 start");
                Thread.yield();
                System.out.println("thread1 end");
            }
        }
    }
    class Thread2 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread2 start");
                System.out.println("thread2 end");
            }
        }
    }
    class Thread3 implements Runnable {
        public void run() {
            System.out.println("thread3 start");
            Thread.yield();
            System.out.println("thread3 end");
        }
    }
}

join 方法

等待调用 join 方法的线程结束之后,程序再继续执行,一般用于等待异步线程执行完结果之后才能继续运行的场景。例如:主线程创建并启动了子线程,如果子线程中要进行大量耗时运算计算某个数据值,而主线程要取得这个数据值才能运行,这时就要用到 join 方法了。

public class ThreadJoinTest {
    private static final Object obj = new Object();
    private static int count = 0;
 
    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(new Thread1());
        Thread t2 = new Thread(new Thread2());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:" + count);
    }
    class Thread1 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread1 start");
                for (int i = 0; i < 10; i++){
                    count++;
                }
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("thread1 end");
            }
        }
    }
    class Thread2 implements Runnable {
        public void run() {
            synchronized (obj) {
                System.out.println("thread2 start");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int i = 0; i < 10; i++){
                    count++;
                }
                System.out.println("thread2 end");
            }
        }
    }
}

线程死锁

任何多线程应用程序都有死锁风险。当一组进程或线程中的每一个都在等待一个只有该组中另一个进程才能引起的事件时,我们就说这组进程或线程死锁了。死锁的最简单情形是:线程 A 持有对象 X 的独占锁,并且在等待对象 Y 的锁,而线程 B 持有对象 Y 的独占锁,却在等待对象 X 的锁。除非有某种方法来打破对锁的等待(Java 锁不支持这种方法),否则死锁的线程将永远等下去。

在这里插入图片描述

我们来看下如下的代码:

public class DeadThreadDemo {
    public static void main(String[] args) throws Exception {
        Object lock1 = new Object();
        Object lock2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (lock1){
                System.out.println("A获取到了锁A");
                TimeUnit.SECONDS.sleep(3);
                synchronized (lock2){
                    System.out.println("A获取到了锁B");
                }
            }
 
 
        });
        t1.start();
        Thread t2 = new Thread(()->{
            synchronized (lock2){
                System.out.println("B获取到了锁B");
                TimeUnit.SECONDS.sleep(3);
                synchronized (lock1){
                    System.out.println("B获取到了锁A");
                }
            }
 
        });
        t2.start();
    }
}

死锁产生的原因

  1. 互斥条件:一个资源只能被一个线程占有,当这个资源被占有后其他线程就只能等待。
  2. 不可剥夺条件:当一个线程不主动释放资源时,此资源一直被拥有线程占有。
  3. 请求并持有条件:线程已经拥有了一个资源后,又尝试请求新的资源。
  4. 环路等待条件:产生死锁一定是发生了线程资源环形链。

死锁的解决方案

知道原因后,我们只需要改变线程的环路访问就可以解决:

public class DeadThreadDemo {
    public static void main(String[] args) throws Exception {
        Object lock1 = new Object();
        Object lock2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (lock1){
                System.out.println("A获取到了锁A");
                TimeUnit.SECONDS.sleep(3);
                synchronized (lock2){
                    System.out.println("A获取到了锁B");
                }
            }
 
 
        });
        t1.start();
        Thread t2 = new Thread(()->{
            synchronized (lock1){
                System.out.println("B获取到了锁A");
                TimeUnit.SECONDS.sleep(3);
                synchronized (lock2){
                    System.out.println("B获取到了锁B");
                }
            }
 
        });
        t2.start();
    }
}

Java 线程池

如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?

在 Java 中可以通过线程池来达到这样的效果。

线程池的好处

  1. 提高效率,创建好一定数量的线程放在池中,等需要使用的时候就从池中拿一个,这要比需要的时候创建一个线程对象要快的多。
  2. 减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可重复执行多个任务。
  3. 提升系统响应速度,假如创建线程用的时间为T1,执行任务用的时间为T2,销毁线程用的时间为T3,那么使用线程池就免去了T1和T3的时间。

线程池的体系结构

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

Executor:一个接口,其定义了一个接收 Runnable 对象的方法 executor, 其方法签名为 executor(Runnable command)。

ExecutorService:是一个比 Executor 使用更广泛的子类接口,其提供了生命周期管理的方法,以及可跟踪一个或多个异步任务执行状况返回 Future 的方法。

AbstractExecutorService:ExecutorService 执行方法的默认实现。

ScheduledExecutorService:一个可定时调度任务的接口。

ScheduledThreadPoolExecutor:ScheduledExecutorService 的实现,一个可定时调度任务的线程池。

ThreadPoolExecutor:线程池,可以通过调用 Executors 以下静态工厂方法来创建线程池并返回一个 ExecutorService 对象。

线程池的分类

Executors类(并发包)提供了4种创建线程池方法,这些方法最终都是通过配置 ThreadPoolExecutor 的不同参数,来达到不同的线程管理效果。

  1. newCacheTreadPool:创建一个可以缓存的线程池,如果线程池长度超过处理需要,可以灵活回收空闲线程,没回收的话就新建线程
  2. newFixedThread:创建一个定长的线程池,可控制最大并发数,超出的线程进行队列等待。
  3. newScheduleThreadPool:可以创建定长的、支持定时任务,周期任务执行。
  4. newSingleExecutor:创建一个单线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

newCachedThreadPool 方式

创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。这种类型的线程池特点是:

  1. 工作线程的创建数量几乎没有限制(其实也有限制的,数目为 Interger.MAX_VALUE),这样可灵活的往线程池中添加线程。
  2. 如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后, 如果你又提交了新的任务,则线程池重新创建一个工作线程。
  3. 在使用 CachedThreadPool 时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪。
  4. 适用场景:任务按照提交次序,一个任务一个任务地逐个执行的场景
ExecutorService cached = Executors.newCachedThreadPool();

Cached.submit(Runnable 的实现对象);

newFixedThreadPool 方式

创建一个定长线程池,可控制线程最大并发数, 每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。 FixedThreadPool 是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。

/*
线程池大小为 5,这个参数指定了可以运行的线程的最大数目,超过这个数目的线程加进去以后,不会运行。
其次,加入线程池的线程属于托管状态,线程的运行不受加入顺序的影响。
*/
ExecutorService fixed= Executors.newFixedThreadPool(5);
fixed.submit(Runnable 的实现对象);

newSingleThreadExecutor 方式

创建一个单线程化的 Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO,优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不 会有多个线程是活动的。

// 不管放入多少个 Runnable 实现对象,执行的都是一个线程
ExecutorService sin= Executors.newSingleThreadExecutor();
sin.submit(Runnable 的实现对象);

newScheduleThreadPool 方式

创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。

ScheduledExecutorService pool=Executors.newScheduledThreadPool(4);
//参数1: task任务  
//参数2: 首次执行任务的延迟时间
//参数3: 周期性执行的时间
//参数4: 时间单位
pool.scheduleAtFixedRate(new TargetTask(), 0, 500, TimeUnit.MILLISECONDS);

submit 方法和 execute 方法的区别?

向线程池提交任务的有两种方式:submit和execute。
execute 方法

void execute(Runnable command): Executor接口中的方法

submit方法

<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);

这3个 submit 方法都是 ExecutorService 接口中的方法。
两种方法的区别:

  1. execute() 方法只能接收 Runnable 类型的参数,而 submit() 方法可以接收 Callable、Runnable 两种类型的参数。
  2. Callable 类型的任务是可以返回执行结果的,而 Runnable 类型的任务不可以返回执行结果。
  3. submit() 提交任务后会有返回值,而 execute() 没有。
  4. submit() 方便 Exception 处理。

线程池的参数

corePoolSize 线程池核心线程大小

corePoolSize 是线程池中的一个最小的线程数量,即使这些线程处理空闲状态,他们也不会被销毁,除非设置了allowCoreThreadTimeOut。

maximumPoolSize 线程池最大线程数量

线程池能够容纳同时执行的最大线程数,此值大于等于1。一个任务被提交到线程池以后,首先会找有没有空闲并且存活线程,如果有则直接将任务交给这个空闲线程来执行,如果没有则会放到工作队列中,直到工作队列满了,才会创建一个新线程,然后从工作队列的头部取出一个任务交由新线程来处理,而将刚提交的任务放入工作队列尾部。线程池不会无限制的去创建新线程,它会有一个最大线程数量的限制,这个数量即由maximunPoolSize指定。工作队列满,且线程数等于最大线程数,此时再提交任务则会调用拒绝策略。

keepAliveTime 多余的空闲线程存活时间

当线程空闲时间达到keepAliveTime值时,多余的线程会被销毁直到只剩下corePoolSize个线程为止。默认情况下:只有当线程池中的线程数大于corePoolSize时keepAliveTime才会起作用,直到线程中的线程数不大于corepoolSIze。

unit 空闲线程存活时间单位

keepAliveTime 的计量单位。

workQueue 工作队列

任务被提交给线程池时,会先进入工作队列,任务调度时再从工作队列中取出。
常用工作队列有以下几种:

  1. ArrayBlockingQueue(数组的有界阻塞队列):在创建时必须设置大小,按FIFO排序(先进先出)。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。
  2. LinkedBlockingQueue(链表的无界阻塞队列):按 FIFO 排序任务,可以设置容量(有界队列),不设置容量则默认使用 Integer.Max_VALUE 作为容量 (无界队列)。该队列的吞吐量高于 ArrayBlockingQueue。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。有两个快捷创建线程池的工厂方法 Executors.newSingleThreadExecutor、Executors.newFixedThreadPool,使用了这个队列,并且都没有设置容量(无界队列)。
  3. SynchronousQueue(一个不缓存任务的阻塞队列):生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。其 吞 吐 量 通 常 高 于LinkedBlockingQueue。 快捷工厂方法 Executors.newCachedThreadPool 所创建的线程池使用此队列。与前面的队列相比,这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。
  4. PriorityBlockingQueue(具有优先级的无界阻塞队列):优先级通过参数Comparator实现。
  5. DelayQueue(这是一个无界阻塞延迟队列):底层基于 PriorityBlockingQueue 实现的,队列中每个元素都有过期时间,当从队列获取元素(元素出队)时,只有已经过期的元素才会出队,而队列头部的元素是过期最快的元素。快捷工厂方法 Executors.newScheduledThreadPool 所创建的线程池使用此队列。

Java 中的阻塞队列(BlockingQueue)与普通队列相比,有一个重要的特点:在阻塞队列为空时,会阻塞当前线程的元素获取操作。具体来说,在一个线程从一个空的阻塞队列中取元素时,线程会被阻塞,直到阻塞队列中有了元素;当队列中有元素后,被阻塞的线程会自动被唤醒(唤醒过程不需要用户程序干预)。

threadFactory 线程工厂

创建一个线程工厂用来创建线程,可以用来设定线程名、是否为daemon线程等等。

handler 拒绝策略

  1. AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常。(默认这种)
  2. DiscardPolicy:丢弃任务,但是不抛出异常
  3. DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程) 。也就是当任务被拒绝添加时,会抛弃任务队列中最旧的任务也就是最先加入队列的,再把这个新任务从队尾添加进去,等待执行。
  4. CallerRunsPolicy:谁调用,谁处理。由调用线程(即提交任务给线程池的线程)处理该任务,如果线程池已经被shutdown则直接丢弃。

线程池的任务调度流程

在这里插入图片描述

  1. 如果当前工作线程数量小于核心线程数量,执行器总是优先创建一个任务线程,而不是从线程队列中获取一个空闲线程。
  2. 如果线程池中总的任务数量大于核心线程池数量,新接收的任务将被加入阻塞队列中,一直到阻塞队列已满。
  3. 当完成一个任务的执行时,执行器总是优先从阻塞队列中获取下一个任务,并开始执行,一直到阻塞队列为空。
  4. 在核心线程池数量已经用完、阻塞队列也已经满了的场景下,如果线程池接收到新的任务,将会为新任务创建一个线程(非核心线程),并且立即开始执行新任务。
  5. 在核心线程都用完、阻塞队列已满的情况下,一直会创建新线程去执行新任务,直到池内的线程总数超出maximumPoolSize。如果线程池的线程总数超过maximumPoolSize,线程池就会拒绝接收任务,当新任务过来时,会为新任务执行拒绝策略 。

注意点:

核心和最大线程数量、BlockingQueue队列等参数如果配置得不合理,可能会造成异步任务得不到预期的并发执行,造成严重的排队等待现象。
线程池的调度器创建线程的一条重要的规则是:在corePoolSize已满之后,还需要等阻塞队列已满,才会去创建新的线程。

线程池的拒绝策略

拒绝情况:

  1. 线程池已经被关闭
  2. 工作队列已满且maximumPoolSize已满

几种常见的拒绝策略:

在这里插入图片描述

AbortPolicy(拒绝策略):新任务就会被拒绝,并且抛出RejectedExecutionException异常。该策略是线程池默认的拒绝策略。

DiscardPolicy(抛弃策略):新任务就会直接被丢掉,并且不会有任何异常抛出。

DiscardOldestPolicy(抛弃最老任务策略):将最早进入队列的任务抛弃,从队列中腾出空间,再尝试加入队列(一般队头元素最老)。

CallerRunsPolicy(调用者执行策略):新任务被添加到线程池时,如果添加失败,那么提交任务线程会自己去执行该任务,不会使用线程池中的线程去执行新任务。

线程池的生命周期

在这里插入图片描述

  1. RUNNING:线程池创建之后的初始状态,这种状态下可以执行任务。
  2. SHUTDOWN:该状态下线程池不再接受新任务,但是会将工作队列中的任务执行完毕。
  3. STOP:该状态下线程池不再接受新任务,也不会处理工作队列中的剩余任务,并且将会中断所有工作线程。
  4. TIDYING:该状态下所有任务都已终止或者处理完成,将会执行 terminated() 钩子方法。
  5. TERMINATED:执行完 terminated() 钩子方法之后的状态。

什么是调度器钩子函数?

设置调度器钩子函数,在系统线程切换时,这个钩子函数将被调用。

线程池有三个主要的钩子方法:

  1. protected void beforeExecute(Thread t, Runnable r) { }: 任务执行之前的钩子方法。
  2. protected void afterExecute(Runnable r, Throwable t) { }: 任务执行之后的钩子方法。
  3. protected void terminated() { }: 线程池终止时的钩子方法。

几种关闭线程池的方法:

  1. shutdown()方法:等待当前工作队列中的剩余任务全部执行完成之后,才会执行关闭,但是此方法被调用之后线程池的状态转为SHUTDOWN,线程池不会再接收新的任务。
  2. shutdownNow()方法:立即关闭线程池的方法,此方法会打断正在执行的工作线程,并且会清空当前工作队列中的剩余任务,返回的是尚未执行的任务。
  3. awaitTermination()方法:等待线程池完成关闭, shutdown() 与 shutdownNow() 方法之后,用户程序都不会主动等待线程池关闭完成。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值