并发编程之Java进程与线程详解(进阶篇)

目录

  • synchronized关键字
    • 线程安全问题的主要诱因
    • 互斥锁的特性
  • synchronized底层实现原理
    • Java对象头
    • Monitor
    • 早期的synchronized为什么效率低下?
    • 在Java6之后,synchronized关键字做了哪些优化?
    • 自旋锁与自适应自旋锁
      • 自旋锁
      • 自适应自旋锁
    • 锁的优化机制
      • 锁消除
      • 锁粗化
      • 偏向锁
      • 轻量级锁
      • 总结
  • JMM(Java内存模型)
    • 主内存
    • 本地内存
    • 主内存与本地内存的数据存储类型以及操作方式归纳
    • JMM如何解决可见性问题
      • 指令重排序
      • happens-before的八大原则
  • volatile关键字
    • volatile修饰的变量为什么对其他线程立即可见
    • volatile如何禁止重排优化?
    • 经典案例-手写单例双重检测(重要)
    • 总结-volatile与synchronized的区别
  • ReentrantLock
    • ReentrantLock公平性的设置
    • synchronized和ReentrantLock的区别
  • CAS(Compare and Swap)
  • 线程池
    • Executor框架
    • 创建线程的方法
      • 利用Excecutor框架中的Executors
    • 利用ThreadPoolExecutor
    • handler饱和策略
    • 新任务提交execute执行后的逻辑
    • 线程池的状态
    • 工作线程的生命周期
    • 线程池的大小如何选定
  • 总结

上一篇,我们讲解了Java进程与线程的基础内容,本篇我们来从原理层级上继续深入理解进程和线程。

synchronized关键字

在详细讲解synchronized关键字前,我们先来了解以下的几个问题。

线程安全问题的主要诱因

  • 存在共享数据(也称为临界资源)
  • 存在多个线程共同操作这些共享数据

解决问题的根本方法:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。此时,我们可以引入互斥锁。

互斥锁的特性

  • 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块进行访问。互斥性也称为操作的原子性
  • 可见性:必须确保在锁被释放之前,对共享变量所做的修改对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一线程可能是在本地缓存的某个副本上继续操作,从而引起数据不一致的问题。

对于Java来说,关键字synchronized就满足了上述互斥锁的特性。它可以保证在同一时刻只有一个线程可以执行某个方法或代码块,同时synchronized也能够保证一个线程共享数据对其他线程是可见的,也就满足了互斥锁的可见性。synchronized锁的不是代码,锁的都是对象。下面,我们根据获取的锁的分类对synchronized进行讲解。

根据获取的锁的分类可分为:

  • 获取对象锁

    获取对象锁的两种方法:

    1. 同步代码块:synchronized(this)synchronized(类的实例对象)。锁的是实例的对象。
    2. 同步非静态方法:synchronized method锁的是当前对象的实例对象。
  • 获取类锁

    获取类锁的两种方法:

    1. 同步代码块:synchronized(类名.class),锁的是类的对象。
    2. 同步静态方法:synchronized static method,锁的是当前对象的类对象。

对象锁和类锁的总结:

  1. 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块。
  2. 类锁和对象锁互不干扰:如果一个线程调用一个实例对象的非静态 synchronized 方法,而另一个线程调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  3. 同一个类的不同对象的对象锁互不干扰。

synchronized底层实现原理

要了解synchronized的底层实现,我们首先需要对Java对象头Monitor有一个认识。

Java对象头

在HotSpot虚拟机中,对象在内存中的布局分为三块区域,分别是:对象头实例数据对齐填充。一般而言,Java的锁对象是存储在对象头中的,其主要结构是由Mark WordClass Metadata Address组成。接下来我们将详细解释。

Mark Word:默认存储对象的hashCode,分代年龄,锁类型,锁标志位等信息。考虑到空间效率,Mark Word被设计成非固定的数据结构,便于存储更多有效的数据。它会根据对象本身的状态复用自己的存储空间。

Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据。

Monitor

Monitor也称为管层、监视器锁,我们可以把它理解成一种同步机制,通常它被描述为一个对象。Monitor对象存在于每个Java对象的对象头中,在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。以下是ObjectMonitor的部分源码:

  // initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

从源码中可以看出,ObjectMonitor有两个队列:_EntryList即我们在上一篇讲到的锁池、_WaitSet即等待池。_owner是指向持有ObjectMonitor对象的线程。当多个线程同时访问同一段同步代码时,线程会先进入到EntryList集合中,当线程获取到对象的monitor后,就会进入到object区域,并把monitor中的owner变量设置为当前线程,同时计数器count就会加一。若线程调用wait方法,将释放当前持有的monitorowner就会被恢复成nullcount也会减一,同时该线程及ObjectWaiter实例就会进入到等待池中等待被唤醒。若当前线程执行完毕,它也会释放monitor锁并复位对应变量的值,以便于其他线程进入获取monitor锁。

看完前置知识后,接下来,我们通过一个demo查看经过synchronized修饰的代码块和方法在.class字节码中有什么区别。

编写如下代码:

SynchronizedTest.java

package com.yeliheng.threads;

public class SynchronizedTest {
    //同步代码块
    public void syncBlock() {
        synchronized (this) {
            System.out.println("测试同步代码块");
        }
    }

    //同步方法
    public synchronized void syncMethod() {
        System.out.println("测试同步方法");
    }

}

我们通过JDK自带的javap命令来查看SynchronizedTest类的字节码信息。首先对该类进行编译。

javac SynchronizedTest.java

编译完成后,我们使用javap来获取字节码信息。

javap -verbose SynchronizedTest.class

可以看到如下结果:

mointorenter-exit

从上图我们可以看出,synchronized同步语句块实现使用的是monitorentermonitorexit指令,monitorenter指令指向同步代码块的开始位置,monitorexit则指向结束位置。当执行monitorenter时,线程将尝试获取对象的锁,若锁的计数器count为0时,则代表锁可被获取,获取后count对应加1。流程图如下:

获取锁

并且由于synchronized是可重入锁,线程在之前若已经获得ObjectMonitor的持有权时,可再次在内部调用synchronized,即重入,重入后的count将再次加1。

重入:从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,线程将会处于阻塞状态,但当一个线程再次请求自己持有的对象锁的临界资源时,是允许的。这种情况叫做重入。

同理,拥有该对象锁的线程可以使用monitorexit来释放锁。在执行monitorexit指令后,锁的计数器将被减1,直至计数器为0,表明锁被释放。这时其他线程才可以尝试获得该锁。

接下来我们继续看synchronized修饰同步方法时的情况:

修饰同步方法

此处,我们可以看到,不同于修饰同步代码块,修饰同步方法时并没有monitorentermonitorexit,而是产生了一个ACC_SYNCHRONIZED标识。这个标识用于区分一个方法是否是同步方法。被设置ACC_SYNCHRONIZED的方法也会按照上文讲述的规则进行加锁,它的本质也是对象监视器。

早期的synchronized为什么效率低下?

  • 早期版本中,synchronized属于重量级锁,依赖于操作系统的Mutex Lock实现。
  • 在线程之间的切换需要从用户态转换到核心态,开销较大。

在Java6之后,synchronized关键字做了哪些优化?

虚拟机在Java6之后,对底层的锁进行了大量的优化,引入了:自适应自旋锁、自旋锁、锁消除、锁粗化、偏向锁、轻量级锁。(下文我们会一一介绍) 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

锁膨胀的方向:无锁->偏向锁->轻量级锁->重量级锁

自旋锁与自适应自旋锁

自旋锁

许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。自旋锁通过让线程执行忙循环等待锁的释放,不让出CPU。另外地,自旋锁与阻塞并不相同。自旋锁自Java4就存在,只是默认为关闭状态,到Java6后才默认开启。

缺点:若锁被其他线程长时间占用,那循环会带来更多性能上的开销。由于线程执行的时间是不好确定的,所以自适应自旋锁应运而生。

自适应自旋锁

自适应自旋锁自旋的次数不再固定,是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

锁的优化机制

锁消除

锁消除也是一种JVM自身的优化机制。在JIT编译时,对运行的上下文进行扫描,去除不可能存在竞争的锁。

锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源。JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。

偏向锁

偏向锁可用于减少同一线程获取锁的代价。在大多数情况下,锁并不存在多线程竞争,而且总是由同一个线程多次获得。为了减少同一个线程获取锁的代价,便引入了偏向锁。偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变成了偏向锁结构,当该线程再次请求时,无需再做任何同步操作。即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。

注意:由于偏向锁的特性,偏向锁并不适用于锁竞争比较激烈的多线程场合。

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。若存在同一时间访问同一把锁的情况,轻量级锁就会膨胀为重量级锁。

轻量级锁适合线程交替执行同步块的应用场景。

总结

优点缺点适用场景
偏向锁加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块或者同步方法的场景
轻量级锁竞争的线程不会阻塞,提高了响应速度若线程长时间抢不到锁,自旋会消耗CPU性能线程交替执行同步块或同步方法的场景
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢,在多线程下频繁地获取和释放锁会带来巨大的性能开销追求吞吐量、同步块或者同步方法执行较长的场景

JMM(Java内存模型)

JMM(Java Memory Model),即Java内存模型,本身是一种抽象的概念,并不真实存在。它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

通俗地讲,Java 内存模型抽象了线程和主内存之间的关系,线程之间的共享变量必须存储在主内存中。其设计的主要目的是为了屏蔽系统和硬件的差异,避免一套代码在不同的平台下产生的效果不一致。

当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中;而当线程获取锁时,Java内存模型会把线程对应的本地内存设置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。这样能够避免主存中的信息和本地内存中的信息不一致的问题,保证数据读取的一致性。

以下通过一张图直观理解JMM。

JMM

主内存

  • 存储Java实例对象,包括成员变量、类信息、常量、静态变量等。
  • 主内存属于数据共享的区域,多线程并发操作时可能会引发线程安全问题。

本地内存

  • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见。(包括字节码行号指示器,Native方法信息等)
  • 本地内存属于线程私有数据区域,所以不存在线程安全问题。

JMM与Java内存区域划分是不同的概念层层次。JMM描述的是一组规则,围绕原子性,有序性,可见性展开。它们的相似点在于:存在共享数据区域和私有数据区域。

主内存与本地内存的数据存储类型以及操作方式归纳

  • 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中。
  • 引用类型的本地变量:引用存储在本地内存中,实例存储在主内存中。
  • 成员变量、static变量、类信息均会被存储在主内存中。
  • 主内存共享的方式是线程各拷贝一份数据到本地内存,操作完成后刷新回主内存。

JMM如何解决可见性问题

要回答此问题,需要提前了解以下前置知识。

指令重排序

由于JVM的指令具有重排的特性,执行语句的顺序可能会与代码中的不同。指令重排在单线程环境下不会出现问题,但在多线程环境下会造成一个线程获取一个未被初始化的实例,就会引发一系列问题。

我们来了解一下指令重排序需要满足的条件。

  1. 在单线程环境下不能改变程序运行的结果。
  2. 存在数据依赖关系的不允许重排序,即无法通过happens-before原则推导出来的,才能进行指令的重排序。

happens-before的八大原则

  1. 程序次序规则: 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则: 一个unlock操作先行发生于后面对同一个锁的lock操作;
  3. volatile变量规则: 对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则: 如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作;
  6. 线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则: 线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则: 一个对象的初始化完成先行发生于它的finalize()方法的开始;

若操作不满足happens-before原则中的任意一条规则,那么这个操作就没有顺序的保障,JVM可以对其进行重排序。若操作A happens-before 操作B,那么操作A在内存上所做的操作对操作B都是可见的。

volatile关键字

volatile是JVM提供的轻量级同步机制。它可以保证被volatile修饰的共享变量对所有线程总是可见的。被volatile修饰的变量对所有线程总是立即可见的。对volatile修饰的变量的所有写操作总是能立即反映到其他线程中,但volatile运算操作在多线程环境中不保证线程安全性。volatile还有一个重要的作用就是防止JVM的指令重排

volatile修饰的变量为什么对其他线程立即可见

原因如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中。当读取一个volatile变量时,JMM会把该线程对应的本地内存置为无效。

volatile如何禁止重排优化?

  1. volatile采用内存屏障(Memory Barrier)来实现。

内存屏障是一条指令,该指令可以对编译器(软件)和处理器(硬件)的指令重排做出一定的限制,比如,一条内存屏障指令可以禁止编译器和处理器将其后面的指令移到内存屏障指令之前。这样做可用于保证特定操作的执行顺序。

  1. 强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。保证变量的内存可见性。

经典案例-手写单例双重检测(重要)

我们先来看一个错误的多线程单例模式的实现。单例模式的相关内容可以查看我的这篇文章:https://www.yeliheng.com/articles/4f2ab91e

public class LazySingleton {

    private static LazySingleton lazySingleton = null; //创建一个空的懒汉式单例

    private LazySingleton() {

    }

    public static LazySingleton getInstance() {
        if (lazySingleton == null) { //第一次检测
          synchronized (LazySingleton.class) {
            if(lazySingleton == null) { //第二次检测
              lazySingleton = new LazySingleton();
            }
          }
        }
        return lazySingleton;
    }
}

此处,我们试图使用synchronized关键字来解决多线程下lazySingleton未被初始化的问题。但代码仍然存在问题。在多线程环境下仍然无法保证其正确的执行。

分析:当某个线程在执行第一次检测时,读取到的lazySingleton对象不为空时,lazySingleton可能还未完全初始化。因为lazySingleton = new LazySingleton();可分解为以下三个步骤:

  1. 分配对象内存空间
  2. 初始化对象
  3. 设置lazySingleton指向刚分配的内存地址,此时lazySingleton != null

这时就可能会发生指令的重排序。1->2->3可能会被重排为:1->3->2。这时因为步骤2和步骤3并不存在数据依赖关系,所以在单线程下执行的结果并不会被改变。但指令重排并不考虑多线程下的语义一致性,故会造成线程安全问题。

解决方法也很简单,使用volatile关键字修饰lazySingleton即可。即private volatile static LazySingleton lazySingleton = null;

总结-volatile与synchronized的区别

  1. volatile本质是在告诉JVM当前变量在寄存器(本地内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住直到该线程完成变量操作为止。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别。
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性。
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

ReentrantLock

ReentrantLock位于JUC包中,它和CountDownLatchFutureTaskSemaphore一样基于AQS实现。关于AQS的具体内容,请关注我的后续文章。ReentrantLock能够实现比synchronized更细粒度的控制,它也是可重入锁。注意,在编码时,调用了lock()方法后,必须调用unlock()释放锁,否则当前线程将一直持有该锁。

ReentrantLock公平性的设置

公平锁:获取锁的顺序按先后调用lock方法的顺序(慎用)。由于Java默认的调度策略很少会导致线程饥饿情况的发生(即线程一直获取不到锁),如果必须指定公平性会造成额外的性能开销。当程序确实有公平性需要时,才有必要指定公平性。

非公平锁:抢占的顺序不一定。

  • ReentrantLock fairLock = new ReentrantLock(true);
  • 参数为true时,倾向于将锁赋予等待时间最久的线程,即公平锁。
  • synchronized是非公平锁。

下面我们通过Demo演示公平锁和非公平锁的效果。

package com.yeliheng.threads;

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockTest implements Runnable{
    //公平锁
    private static ReentrantLock lock = new ReentrantLock(true);

    @Override
    public void run() {
        while (true) {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " 得到锁");
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        ReentrantLockTest reentrantLockTest = new ReentrantLockTest();
        Thread thread1 = new Thread(reentrantLockTest);
        Thread thread2 = new Thread(reentrantLockTest);
        thread1.start();
        thread2.start();
    }
}

输出结果如下:

Thread-0 得到锁
Thread-1 得到锁
Thread-0 得到锁
Thread-1 得到锁
Thread-0 得到锁
Thread-1 得到锁

可以发现,线程得到锁的概率都是一样的,始终按照0,1,0,1的顺序获取到锁。这就是公平锁。

下面我们将fair参数修改为false,再来看看输出结果。

Thread-0 得到锁
Thread-0 得到锁
Thread-0 得到锁
Thread-0 得到锁
Thread-0 得到锁

执行后我们会发现线程0一直获取到锁,即非公平锁。

ReentrantLock还可以有以下用途:

  • 判断是否有线程或者某个特定线程在排队等待获取锁。
  • 带超时的获取锁的尝试。
  • 感知有没有成功获取锁。
  • 锁可以绑定多个条件。此处需要使用Condition接口与newCondition()方法。

Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能。即在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

synchronized和ReentrantLock的区别

  • sychronized是关键字,ReentrantLock是类,所以ReentrantLocksynchronized增加了一些高级的功能。
  • ReentrantLock可以对获取锁的等待时间进行设置,避免死锁。
  • ReentrantLock可以获取各种锁的信息。
  • ReentrantLock可以灵活地实现多路通知。
  • 机制:sync操作Mark Wordlock调用Unsafe类的park()方法。

CAS(Compare and Swap)

CAS是一种乐观锁,号称lock-free,它是一种高效实现线程安全性的方法,支持原子更新操作,适用于计数器、序列发生器等场景。CAS操作失败时由开发者决定是否继续尝试或执行其他操作。

CAS包含三个操作数:内存位置(V)、预期原值(A)、新值(B)。执行CAS操作时,会将内存位置的值与预期原值进行比较,若匹配,那么处理器会将该位置的值更新为新值,否则处理器不做任何操作。在大多数情况下,Java开发者并不需要通过CAS代码实现线程安全的容器,更多是通过JUC包提供的Atomic原子类进行相关操作。

CAS的缺点:

  • 若循环时间长,则开销会很大。
  • 只能保证一个共享变量的原子操作
  • ABA问题:即一个变量经历了从被复制为A,B,A的过程,但CAS机制无法分辨该变量是否中途被改变过,会误认为其处在原来的值。这个漏洞就是ABA问题。JUC为了解决这个问题,提供了AtomicStampedReference类,它能够通过控制变量值的版本来保证CAS的正确性。

线程池

线程池为多个线程提供了一种统一的管理方式,通过线程池我们可以重复地利用线程的资源而无需进行频繁地创建。

使用线程池的好处:

  • 降低资源消耗:通过重复利用已经创建的线程来减少创建和销毁线程时带来的性能开销。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
  • 提高响应速度:当任务到达时,可以无需等待线程创建就能够立即执行。

Executor框架

Executor框架是一个根据一组执行策略调用、调度、执行和控制的异步任务的框架。目的是提供一种将任务提交与任务运行分离开来的方式。框架图如下:

Executor

JUC中有三个Executor接口

  • Executor: 运行新任务的简单接口,将任务提交和任务执行细节解耦。
  • ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善。
  • ScheduledExecutorService:支持Future和定期执行的任务。

创建线程的方法

利用Excecutor框架中的Executors

Executors提供了多种不同的线程池:

  1. newFixedThreadPool(int nThreads)指定工作线程数量的线程池

  2. newCachedThreadPool()处理大量短时间工作任务的线程池。 (1)试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程。

    (2)如果线程闲置的时间超过阈值,则会被终止并移出缓存。

    (3)系统长时间闲置的时候,不会消耗什么资源。

  3. newSingleThreadExecutor():创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它。

  4. newSingleThreadScheduledExecutor()newScheduledThreadPool(int corePoolSize)定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程。

  5. newWorkStealingPool():内部会构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序。

Fork/Join框架是一个用于并行执行任务的框架。它能够把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架。该框架是ExecutorService接口的一种具体实现,目的是为了帮助开发者更好地利用多处理器。

Work-Stealing算法:某个线程从其他队列里窃取任务来执行。

利用ThreadPoolExecutor

ThreadPoolExecutor 类中提供的四个构造方法,我们重点来看下其构造方法中的重要参数。

  • corePoolSize:核心线程数量。
  • maximumPoolSize:线程不够用时能够创建的最大线程数。
  • workQueue:任务等待队列。
  • keepAliveTime:当线程池中的线程数量大于 corePoolSize时,若此时没有新任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁。
  • threadFactory:线程工厂,创建新线程时使用到。
  • handler:饱和策略。

handler饱和策略

  • AbortPolicy:直接抛出异常,这是默认策略
  • CallerRunsPolicy:用调用者所在的线程来执行任务
  • DiscardOldestPolicy:丢弃队列中最靠前的任务,并执行当前任务
  • DiscardPolicy:直接丢弃任务
  • 实现RejectExecutionHandler接口的自定义handler

新任务提交execute执行后的逻辑

  • 如果运行的线程少于corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的;
  • 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时才创建新的线程去处理任务;
  • 如果设置的corePoolSizemaximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理;
  • 如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的饱和策略来处理任务;

流程图如下:

逻辑

线程池的状态

  • RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务。
  • SHUTDOWN:不再接受新提交的任务,但可以处理存量任务。
  • STOP:不再接受新提交的任务,也不处理存量任务。
  • TIDYING:所有的任务均已终止。
  • TERMINATED:terminated()方法执行完成后进入该状态。

状态转换图如下:

转换图

工作线程的生命周期

生命周期图

线程池的大小如何选定

  • CPU密集型:线程数 = 按照CPU核数或者核数+1设定
  • I/O密集型:线程数=CPU核数 * (1 + 平均等待时间 / 平均工作时间)

总结

本文讲解了synchronized关键字以及底层实现原理,进而讲解JDK中常见的锁,再引入了JMM内存模型,并讲解了volatile关键字以及ReentrantLock锁、CAS机制,最后讲解了线程池。

文章字数较多,但都是满满的干货哦。

Java多线程并发编程
2025 © Yeliheng的技术小站 版权所有