Java开发面试指南系列-多线程

该篇主要讲Java多线程相关面试点

1.什么是线程和进程?

何为进程?

进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,因此进程是动态的。系统运⾏⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。

Java 中,当我们启动 main 函数时其实就是启动了⼀个 JVM 的进程,⽽ main 函数所在的线程就是这个进程中的⼀个线程,也称主线程。

如下图所示,在 windows 中通过查看任务管理器的⽅式,我们就可以清楚看到 window 当前运⾏的进程(.exe ⽂件的运⾏)。

2024010511344585

何为线程?

线程与进程相似,但线程是⼀个⽐进程更⼩的执⾏单位。⼀个进程在其执⾏的过程中可以产⽣多个线程。与进程不同的是同类的多个线程共享进程的堆和⽅法区资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈,所以系统在产⽣⼀个线程,或是在各个线程之间作切换⼯作时,负担要⽐进程⼩得多,也正因为如此,线程也被称为轻量级进程。

Java 程序天⽣就是多线程程序,我们可以通过 JMX 来看⼀下⼀个普通的 Java 程序有哪些线程,代码如下。

public class MultiThread {
  public static void main(String[] args) {
    // 获取 Java 线程管理 MXBean
    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
    // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
    ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
    // 遍历线程信息,仅打印线程 ID 和线程名称信息
    for (ThreadInfo threadInfo: threadInfos) {
        System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
    }
  }
}

上述程序输出如下(输出内容可能不同,不⽤太纠结下⾯每个线程的作⽤,只⽤知道 main 线程执⾏main ⽅法即可):

[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调⽤对象 finalize ⽅法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序⼊⼝

从上⾯的输出内容可以看出:⼀个 Java 程序的运⾏是 main 线程和多个其他线程同时运⾏。

2.请简要描述线程与进程的关系,区别及优缺点?

从 JVM ⻆度说进程和线程之间的关系,图解进程和线程的关系,下图是 Java 内存区域,通过下图我们从 JVM 的⻆度来说⼀下线程和进程之间的关系。

2024010511432716

从上图可以看出:⼀个进程中可以有多个线程,多个线程共享进程的堆和⽅法区 (JDK1.8 之后的元空间)资源,但是每个线程有⾃⼰的程序计数器虚拟机栈本地⽅法栈

总结: 线程 是 进程 划分成的更⼩的运⾏单位。线程和进程最⼤的不同在于基本上各进程是独⽴的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响。线程执⾏开销⼩,但不利于资源的管理和保护;⽽进程正相反

下⾯是该知识点的扩展内容!下⾯来思考这样⼀个问题:为什么程序计数器虚拟机栈本地⽅法栈是线程私有的呢?为什么堆和⽅法区是线程共享的呢?

程序计数器为什么是私有的?

程序计数器主要有下⾯两个作⽤:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。

需要注意的是,如果执⾏的是 native ⽅法,那么程序计数器记录的是 undefined 地址,只有执⾏的是 Java 代码时程序计数器记录的才是下⼀条指令的地址。所以,程序计数器私有主要是为了线程切换后能恢复到正确的执⾏位置

虚拟机栈和本地⽅法栈为什么是私有的?

  • 虚拟机栈: 每个 Java ⽅法在执⾏的同时会创建⼀个栈帧⽤于存储局部变量表、操作数栈、常量池引⽤等信息。从⽅法调⽤直⾄执⾏完成的过程,就对应着⼀个栈帧在 Java 虚拟机栈中⼊栈和出栈的过程。
  • 本地⽅法栈: 和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法(也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。

⼀句话简单了解堆和⽅法区

堆和⽅法区是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存),⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

3.说说并发与并⾏的区别?

  • 并发: 同⼀时间段,多个任务都在执⾏ (单位时间内不⼀定同时执⾏);
  • 并⾏: 单位时间内,多个任务同时执⾏。

4.为什么要使⽤多线程呢?

先从总体上来说:

  • 从计算机底层来说: 线程可以⽐作是轻量级的进程,是程序执⾏的最⼩单位,线程间的切换和调度的成本远远⼩于进程。另外,多核 CPU 时代意味着多个线程可以同时运⾏,这减少了线程上下⽂切换的开销。
  • 从当代互联⽹发展趋势来说: 现在的系统动不动就要求百万级甚⾄千万级的并发量,⽽多线程并发编程正是开发⾼并发系统的基础,利⽤好多线程机制可以⼤⼤提⾼系统整体的并发能⼒以及性能。

再深⼊到计算机底层来探讨:

  • 单核时代: 在单核时代多线程主要是为了提⾼ CPU 和 IO 设备的综合利⽤率。举个例⼦:当只有⼀个线程的时候会导致 CPU 计算时,IO 设备空闲;进⾏ IO 操作时,CPU 空闲。我们可以简单地说这两者的利⽤率⽬前都是 50%左右。但是当有两个线程的时候就不⼀样了,当⼀个线程执⾏ CPU 计算时,另外⼀个线程可以进⾏ IO 操作,这样两个的利⽤率就可以在理想情况下达到100%了。
  • 多核时代: 多核时代多线程主要是为了提⾼ CPU 利⽤率。举个例⼦:假如我们要计算⼀个复杂的任务,我们只⽤⼀个线程的话,CPU 只会⼀个 CPU 核⼼被利⽤到,⽽创建多个线程就可以让多个 CPU 核⼼被利⽤到,这样就提⾼了 CPU 的利⽤率。

5.使⽤多线程可能带来什么问题?

并发编程的⽬的就是为了能提⾼程序的执⾏效率提⾼程序运⾏速度,但是并发编程并不总是能提⾼程序运⾏速度的,⽽且并发编程可能会遇到很多问题,⽐如:内存泄漏、上下⽂切换、死锁还有受限于硬件和软件的资源闲置问题。

6.说说线程的⽣命周期和状态?

Java 线程在运⾏的⽣命周期中的指定时刻只可能处于下⾯ 6 种不同状态的其中⼀个状态(图源《Java并发编程艺术》4.1.4 节)。

2024010513571622

线程在⽣命周期中并不是固定处于某⼀个状态⽽是随着代码的执⾏在不同状态之间切换。Java 线程状态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节):

2024010513590146

由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调⽤ start() ⽅法后开始运⾏,线程这时候处于 READY(可运⾏) 状态。可运⾏状态的线程获得了 CPU 时间⽚(timeslice)后就处于RUNNING(运⾏) 状态。

2024010514051579

当线程执⾏ wait() ⽅法之后,线程进⼊ WAITING(等待) 状态。进⼊等待状态的线程需要依靠其他线程的通知才能够返回到运⾏状态,⽽ TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,⽐如通过 sleep(long millis) ⽅法或 wait(long millis) ⽅法可以将 Java线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调⽤同步⽅法时,在没有获取到锁的情况下,线程将会进⼊到 BLOCKED(阻塞) 状态。线程在执⾏Runnable 的 run() ⽅法之后将会进⼊到 TERMINATED(终⽌) 状态。

7.什么是上下文切换?

多线程编程中⼀般线程的个数都⼤于 CPU 核⼼的个数,⽽⼀个 CPU 核⼼在任意时刻只能被⼀个线程使⽤,为了让这些线程都能得到有效执⾏,CPU 采取的策略是为每个线程分配时间⽚并轮转的形式。当⼀个线程的时间⽚⽤完的时候就会重新处于就绪状态让给其他线程使⽤,这个过程就属于⼀次上下⽂切换。

概括来说就是:当前任务在执⾏完 CPU 时间⽚切换到另⼀个任务之前会先保存⾃⼰的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是⼀次上下⽂切换

上下⽂切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒⼏⼗上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下⽂切换对系统来说意味着消耗⼤量的 CPU 时间,事实上,可能是操作系统中时间消耗最⼤的操作。

Linux 相⽐与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有⼀项就是,其上下⽂切换和模式切换的时间消耗⾮常少。

8.什么是线程死锁?如何避免死锁?

认识线程死锁

线程死锁描述的是这样⼀种情况:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对⽅的资源,所以这两个线程就会互相等待⽽进⼊死锁状态。

2024010514100083

下⾯通过⼀个例⼦来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):

public class DeadLockDemo
{
    private static Object resource1 = new Object(); //资源 1
    private static Object resource2 = new Object(); //资源 2
    public static void main(String[] args){
      new Thread(() i >{
        synchronized(resource1)
      {
      System.out.println(Thread.currentThread() + "get
         resource1 ");
      try{
        Thread.sleep(1000);
     }catch(InterruptedException e){
      e.printStackTrace();
     }
       System.out.println(Thread.currentThread() + "waiting getresource2 ");
      synchronized(resource2){
        System.out.println(Thread.currentThread() + "getresource2 ");
     }
    }
   }, "线程 1").start();
     new Thread(() i >{
       synchronized(resource2){
     System.out.println(Thread.currentThread() + "getresource2 ");
    try{
      Thread.sleep(1000);
   }catch(InterruptedException e){
     e.printStackTrace();
   }
     System.out.println(Thread.currentThread() + "waiting getresource1 ");
      synchronized(resource1){
       System.out.println(Thread.currentThread() + "getresource1 ");
    }
   }
  }, "线程 2").start();
 }
}

 

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000); 让线程 A 休眠 1s 为的是让线程 B 得到执⾏然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对⽅的资源,然后这两个线程就会陷⼊互相等待的状态,这也就产⽣了死锁。上⾯的例⼦符合产⽣死锁的四个必要条件。

学过操作系统的朋友都知道产⽣死锁必须具备以下四个条件:

  1. 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
  2. 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源。
  4. 循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。

如何避免线程死锁?

我上⾯说了产⽣死锁的四个必要条件,为了避免死锁,我们只要破坏产⽣死锁的四个条件中的其中⼀个就可以了。现在我们来挨个分析⼀下:

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏请求与保持条件 :⼀次性申请所有的资源。
  3. 破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放。破坏循环等待条件

我们分析⼀下上⾯的代码为什么避免了死锁的发⽣?

线程 1 ⾸先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占⽤,线程 2 获取到就可以执⾏了。这样就破坏了破坏循环等待条件,因此避免了死锁。

9.说说 sleep() ⽅法和 wait() ⽅法区别和共同点?

  • 两者最主要的区别在于:sleep ⽅法没有释放锁,⽽ wait ⽅法释放了锁 。
  • 两者都可以暂停线程的执⾏。
  • Wait 通常被⽤于线程间交互/通信,sleep 通常被⽤于暂停执⾏。
  • wait() ⽅法被调⽤后,线程不会⾃动苏醒,需要别的线程调⽤同⼀个对象上的 notify() 或者notifyAll() ⽅法。sleep() ⽅法执⾏完成后,线程会⾃动苏醒。或者可以使⽤ wait(longtimeout)超时后线程会⾃动苏醒。

10.为什么我们调⽤ start() ⽅法时会执⾏ run() ⽅法,为什么我们不能直接调⽤run() ⽅法?

这是另⼀个⾮常经典的 java 多线程⾯试问题,⽽且在⾯试中会经常被问到。很简单,但是很多⼈都会答不上来!

new ⼀个 Thread,线程进⼊了新建状态;调⽤ start() ⽅法,会启动⼀个线程并使线程进⼊了就绪状态,当分配到时间⽚后就可以开始运⾏了。 start() 会执⾏线程的相应准备⼯作,然后⾃动执⾏run() ⽅法的内容,这是真正的多线程⼯作。 ⽽直接执⾏ run() ⽅法,会把 run ⽅法当成⼀个 main线程下的普通⽅法去执⾏,并不会在某个线程中执⾏它,所以这并不是多线程⼯作。

总结: 调⽤ start ⽅法⽅可启动线程并使线程进⼊就绪状态,⽽ run ⽅法只是 thread 的⼀个普通⽅法调⽤,还是在主线程⾥执⾏

11.synchronized 关键字

1.说⼀说⾃⼰对于 synchronized 关键字的了解

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执⾏。

另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原⽣线程之上的。如果要挂起或者唤醒⼀个线程,都需要操作系统帮忙完成,⽽操作系统实现线程之间的切换时需要从⽤户态转换到内核态,这个状态之间的转换需要相对⽐᫾⻓的时间,时间成本相对᫾⾼,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官⽅对从 JVM 层⾯对synchronized较⼤优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引⼊了⼤量的优化,如⾃旋锁、适应性⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

2.说说⾃⼰是怎么使⽤ synchronized 关键字,在项⽬中⽤到了吗

3.synchronized关键字最主要的三种使⽤⽅式

  • 修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁
  • 修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管new了多少个对象,只有⼀份)。所以如果⼀个线程A调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程B需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态⽅法和 synchronized(class)代码块上都是是给 Class类上锁。synchronized 关键字加到实例⽅法上是给对象实例上锁。尽量不要使⽤synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!

下⾯我以⼀个常⻅的⾯试题为例讲解⼀下 synchronized 关键字的具体使⽤。

⾯试中⾯试官经常会说:“单例模式了解吗?来给我⼿写⼀下!给我解释⼀下双重检验锁⽅式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {}
    public synchronized static Singleton getUniqueInstance() {
    //先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
    if (uniqueInstance WX null) {
    //类对象加锁
       synchronized(Singleton.class) {
    if (uniqueInstance WX null) {
       uniqueInstance = new Singleton();
     }
    }
   }
   return uniqueInstance;
  }
}

另外,需要注意 uniqueInstance 采⽤ volatile 关键字修饰也是很有必要。

uniqueInstance 采⽤ volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton();这段代码其实是分为三步执⾏:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执⾏顺序有可能变成 1i>3i>2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执⾏了 1 和3,此时 T2 调⽤ getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回uniqueInstance,但此时 uniqueInstance 还未被初始化。使⽤ volatile 可以禁⽌ JVM 的指令重排,保证在多线程环境下也能正常运⾏。

4.讲⼀下 synchronized 关键字的底层原理

synchronized 关键字底层原理属于 JVM 层⾯。

① synchronized 同步语句块的情况

public class SynchronizedDemo {
  public void method() {
    synchronized (this) {
      System.out.println("synchronized 代码块");
   }
  }
}

通过 JDK ⾃带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:⾸先切换到类的对应⽬录执⾏ javac SynchronizedDemo.java 命令⽣成编译后的 .class ⽂件,然后执⾏ javap -c -s-v -l SynchronizedDemo.class 。

2024010514455167

从上⾯我们可以看出:

synchronized 同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中 monitorenter指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执⾏monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因)的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。

② synchronized 修饰⽅法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
      System.out.println("synchronized ⽅法");
    }
}

2024010514481270

synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。

5.说说 JDK1.6 之后的synchronized 关键字底层做了哪些优化,可以详细介绍⼀下这些优化吗

JDK1.6 对锁的实现引⼊了⼤量的优化,如偏向锁、轻量级锁、⾃旋锁、适应性⾃旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:⽆锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈⽽逐渐升级。注意锁可以升级不可降级,这种策略是为了提⾼获得锁和释放锁的效率。

6.谈谈 synchronized和ReentrantLock 的区别

① 两者都是可重⼊锁

两者都是可重⼊锁。“可重⼊锁”概念是:⾃⼰可以再次获取⾃⼰的内部锁。⽐如⼀个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重⼊的话,就会造成死锁。同⼀个线程每次获取锁,锁的计数器都⾃增1,所以要等到锁的计数器下降为0时才能释放锁。

② synchronized 依赖于 JVM ⽽ ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前⾯我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进⾏了很多优化,但是这些优化都是在虚拟机层⾯实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层⾯实现的(也就是 API 层⾯,需要 lock() 和 unlock() ⽅法配合try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

③ ReentrantLock ⽐ synchronized 增加了⼀些⾼级功能

相⽐synchronized,ReentrantLock增加了⼀些⾼级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReentrantLock提供了⼀种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReentrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是⾮公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair) 构造⽅法来制定是否是公平的。
  • synchronized关键字与wait()和notify()/notifyAll()⽅法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接⼝与newCondition() ⽅法。Condition是JDK1.5之后才有的,它具有很好的灵活性,⽐如可以实现多路通知功能也就是在⼀个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从⽽可以有选择性的进⾏线程通知,在调度线程上更加灵活。 在使⽤notify()/notifyAll()⽅法进⾏通知时,被通知的线程是由 JVM 选择的,⽤ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能⾮常重要,⽽且是Condition接⼝默认提供的。⽽synchronized关键字就相当于整个Lock对象中只有⼀个Condition实例,所有的线程都注册在它⼀个身上。如果执⾏notifyAll()⽅法的话就会通知所有处于等待状态的线程这样会造成很⼤的效率问题,⽽Condition实例的signalAll()⽅法 只会唤醒注册在该Condition实例中的所有等待线程。

如果你想使⽤上述功能,那么选择ReentrantLock是⼀个不错的选择。

④ 性能已不是选择标准

12.volatile关键字

讲⼀下Java内存模型

在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进⾏特别的注意的。⽽在当前的 Java 内存模型下,线程可以把变量保存本地内存(⽐如机器的寄存器)中,⽽不是直接在主存中进⾏读写。这就可能造成⼀个线程在主存中修改了⼀个变量的值,⽽另外⼀个线程还继续使⽤它在寄存器中的变量值的拷⻉,造成数据的不⼀致。

2024010602114329

要解决这个问题,就需要把变量声明为volatile,这就指示 JVM,这个变量是不稳定的,每次使⽤它都到主存中进⾏读取。说⽩了, volatile 关键字的主要作⽤就是保证变量的可⻅性然后还有⼀个作⽤是防⽌指令重排序。

2024010602125712

并发编程的三个重要特性

  1. 原⼦性 : ⼀个的操作或者多次操作,要么所有的操作全部都得到执⾏并且不会收到任何因素的⼲扰⽽中断,要么所有的操作都执⾏,要么都不执⾏。 synchronized 可以保证代码⽚段的原⼦性。
  2. 可⻅性 :当⼀个变量对共享变量进⾏了修改,那么另外的线程都是⽴即可以看到修改后的最新值。 volatile 关键字可以保证共享变量的可⻅性。
  3. 有序性 :代码在执⾏的过程中的先后顺序,Java 在编译器以及运⾏期间的优化,代码的执⾏顺序未必就是编写代码时候的顺序。 volatile 关键字可以禁⽌指令进⾏重排序优化。

说说 synchronized 关键字和 volatile 关键字的区别

  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定⽐synchronized关键字要好。但是volatile关键字只能⽤于变量⽽synchronized关键字可以修饰⽅法以及代码块。synchronized关键字在JavaSE1.6之后进⾏了主要包括为了减少获得锁和释放锁带来的性能消耗⽽引⼊的偏向锁和轻量级锁以及其它各种优化之后执⾏效率有了显著提升,实际开发中使⽤synchronized 关键字的场景还是更多⼀些。
  • 多线程访问volatile关键字不会发⽣阻塞,⽽synchronized关键字可能会发⽣阻塞
  • volatile关键字能保证数据的可⻅性,但不能保证数据的原⼦性。synchronized关键字两者都能保证。
  • volatile关键字主要⽤于解决变量在多个线程之间的可⻅性,⽽ synchronized关键字解决的是多个线程之间访问资源的同步性。

13.ThreadLocal

1.ThreadLocal简介

通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的专属本地变量该如何解决呢? JDK中提供的 ThreadLocal 类正是为了解决这样的问题。ThreadLocal 类主要解决的就是让每个线程绑定⾃⼰的值,可以将 ThreadLocal 类形象的⽐喻成存放数据的盒⼦,盒⼦中可以存储每个线程的私有数据。

如果你创建了⼀个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使⽤ get() 和 set() ⽅法来获取默认值或将其值更改为当前线程所存的副本的值,从⽽避免了线程安全问题。

再举个简单的例⼦:

⽐如有两个⼈去宝屋收集宝物,这两个共⽤⼀个袋⼦的话肯定会产⽣争执,但是给他们两个⼈每个⼈分配⼀个袋⼦的话就不会出现这样的问题。如果把这两个⼈⽐作线程的话,那么ThreadLocal就是⽤来避免这两个线程竞争的。

2.ThreadLocal示例

相信看了上⾯的解释,⼤家已经搞懂 ThreadLocal 类是个什么东⻄了。

import java.text.SimpleDateFormat;
import java.util.Random;
public class ThreadLocalExample implements Runnable {
  // SimpleDateFormat 不是线程安全的,所以每个线程都要有⾃⼰独⽴的副本
  private static final ThreadLocal < SimpleDateFormat > formatter = ThreadLocal.withInitial(() i > new SimpleDateFormat("yyyyMMdd HHmm"));
  public static void main(String[] args) throws InterruptedException {
    ThreadLocalExample obj = new ThreadLocalExample();
    for (int i = 0; i < 10; i++) {
      Thread t = new Thread(obj, "" + i);
      Thread.sleep(new Random().nextInt(1000));
      t.start();
    }
  }
  @Override 
  public void run() {
    System.out.println("Thread Name=" + Thread.currentThread().getName() + " default Formatter = " + formatter.get().toPattern());
    try {
      Thread.sleep(new Random().nextInt(1000));
    } catch(InterruptedException e) {
      e.printStackTrace();
    }
    //formatter pattern is changed here by thread, but it won't
    reflect to other threads formatter.set(new SimpleDateFormat());
    System.out.println("Thread Name=" + Thread.currentThread().getName() + " formatter = " + formatter.get().toPattern());
  }
}

Output:

Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm

从输出中可以看出,Thread-0已经改变了formatter的值,但仍然是thread-2默认格式化程序与初始化值相同,其他线程也⼀样。上⾯有⼀段代码⽤到了创建 ThreadLocal 变量的那段代码⽤到了 Java8 的知识,它等于下⾯这段代码,如果你写了下⾯这段代码的话,IDEA会提示你转换为Java8的格式(IDEA真的不错!)。因为ThreadLocal类在Java 8中扩展,使⽤⼀个新的⽅法 withInitial() ,将Supplier功能接⼝作为参数。

private static final ThreadLocal formatter = new ThreadLocal(){ 
    @Override protected SimpleDateFormat initialValue() { 
      return new SimpleDateFormat("yyyyMMdd HHmm"); 
    } 
};

3.ThreadLocal原理

从 Thread 类源代码⼊⼿。

public class Thread implements Runnable {
......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
......
}

从上⾯ Thread 类 源代码可以看出 Thread 类中有⼀个 threadLocals 和 ⼀个inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap 。默认情况下这两个变量都是null,只有当前线程调⽤ ThreadLocal 类的 set 或 get ⽅法时才创建它们,实际上调⽤这两个⽅法的时候,我们调⽤的是 ThreadLocalMap 类对应的 get() 、 set() ⽅法。

ThreadLocal 类的 set() ⽅法

public void set(T value) {
  Thread t = Thread.currentThread();
  ThreadLocalMap map = getMap(t);
  if (map != null)
    map.set(this, value);
  else
    createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
  return t.threadLocals;
}

通过上⾯这些内容,我们⾜以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap中,并不是存在 ThreadLocal 上, ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

ThreadLocal 内部维护的是⼀个类似 Map 的 ThreadLocalMap 数据结构, key 为当前对象的Thread 对象,值为 Object 对象。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
......
}

⽐如我们在同⼀个线程中声明了两个 ThreadLocal 对象的话,会使⽤ Thread 内部都是使⽤仅有那个 ThreadLocalMap 存放数据的, ThreadLocalMap 的 key 就是 ThreadLocal 对象,value就是 ThreadLocal 对象调⽤ set ⽅法设置的值。

2024010602291779

ThreadLocalMap 是 ThreadLocal 的静态内部类。

2024010602303846

4.ThreadLocal 内存泄露问题

ThreadLocalMap 中使⽤的 key 为 ThreadLocal 的弱引⽤,⽽ value 是强引⽤。所以,如果ThreadLocal 没有被外部强引⽤的情况下,在垃圾回收的时候,key 会被清理掉,⽽ value 不会被清理掉。这样⼀来, ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远⽆法被GC 回收,这个时候就可能会产⽣内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null 的记录。使⽤完ThreadLocal ⽅法后 最好⼿动调⽤ remove() ⽅法

static class Entry extends WeakReference<ThreadLocal<?jk {
  /** The value associated with this ThreadLocal. */
  Object value;
  Entry(ThreadLocal<?> k, Object v) {
    super(k);
    value = v;
  }
}

弱引⽤介绍:

如果⼀个对象只具有弱引⽤,那就类似于可有可⽆的⽣活⽤品。弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。弱引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。

14.线程池

1.为什么要⽤线程池?

池化技术相⽐⼤家已经屡⻅不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应⽤。池化技术的思想主要是为了减少每次获取资源的消耗,提⾼对资源的利⽤率。线程池提供了⼀种限制和管理资源(包括执⾏⼀个任务)。 每个线程池还维护⼀些基本统计信息,例如已完成任务的数量。这⾥借⽤《Java 并发编程的艺术》提到的来说⼀下使⽤线程池的好处

  • 降低资源消耗:通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
  • 提⾼响应速度:当任务到达时,任务可以不需要的等到线程创建就能⽴即执⾏。
  • 提⾼线程的可管理性:线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。

2.实现Runnable接⼝和Callable接⼝的区别

Runnable ⾃Java 1.0以来⼀直存在,但 Callable 仅在Java 1.5中引⼊,⽬的就是为了来处理 Runnable 不⽀持的⽤例。 Runnable 接⼝不会返回结果或抛出检查异常,但是 Callable 接⼝可以。所以,如果任务不需要返回结果或抛出异常推荐使⽤ Runnable 接⼝,这样代码看起来会更加简洁。

⼯具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。( Executors.callable(Runnable task )或 Executors.callable(Runnable task,Object resule) )。

Runnable.java

@FunctionalInterface
public interface Runnable {
  /**
  * 被线程执⾏,没有返回值也⽆法抛出异常
  */
  public abstract void run();
}

Callable.java

@FunctionalInterface 
public interface Callable { 
  /** 
  * 计算结果,或在⽆法这样做时抛出异常。 
  * @return 计算得出的结果 
  * @throws 如果⽆法计算结果,则抛出异常 
  */ 
  V call() throws Exception; 
}

3.执⾏execute()⽅法和submit()⽅法的区别是什么呢?

  1. execute() ⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执⾏成功与否;
  2. submit() ⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执⾏成功,并且可以通过 Future 的 get() ⽅法来获取返回值, get() ⽅法会阻塞当前线程直到任务完成,⽽使⽤ get(long timeout,TimeUnit unit) ⽅法则会阻塞当前线程⼀段时间后⽴即返回,这时候有可能任务没有执⾏完。

我们以 AbstractExecutorService 接⼝中的⼀个 submit ⽅法为例⼦来看看源代码:

public Future<?> submit(Runnable task) { 
  if (task WX null) 
    throw new NullPointerException(); 
  RunnableFuture ftask = newTaskFor(task, null); 
  execute(ftask); return ftask; 
}

上⾯⽅法调⽤的 newTaskFor ⽅法返回了⼀个 FutureTask 对象。

protected  RunnableFuture newTaskFor(Runnable runnable, T value) {
 return new FutureTask(runnable, value); 
}

我们再来看看 execute() ⽅法:

public void execute(Runnable command) {
...
}

4.如何创建线程池

《阿⾥巴巴Java开发⼿册》中强制线程池不允许使⽤ Executors 去创建,⽽是通过ThreadPoolExecutor 的⽅式,这样的处理⽅式让写的同学更加明确线程池的运⾏规则,规避资源耗尽的⻛险

Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列⻓度为 Integer.MAX_VALUE,可能堆积⼤量的请求,从⽽导致OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,可能会创建⼤量线程,从⽽导致OOM。

⽅式⼀:通过构造⽅法实现

2024010602410912

⽅式⼆:通过Executor 框架的⼯具类Executors来实现 我们可以创建三种类型的ThreadPoolExecutor:

  • FixedThreadPool : 该⽅法返回⼀个固定线程数量的线程池。该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor: ⽅法返回⼀个只有⼀个线程的线程池。若多余⼀个任务被提交到该线程池,任务会被保存在⼀个任务队列中,待线程空闲,按先⼊先出的顺序执⾏队列中的任务。
  • CachedThreadPool: 该⽅法返回⼀个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复⽤,则会优先使⽤可复⽤的线程。若所有线程均在⼯作,⼜有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执⾏完毕后,将返回线程池进⾏复⽤。

对应Executors⼯具类中的⽅法如图所示:

2024010602425533

5.ThreadPoolExecutor 类分析

ThreadPoolExecutor 类中提供的四个构造⽅法。我们来看最⻓的那个,其余三个都是在这个构造⽅法的基础上产⽣(其他⼏个构造⽅法说⽩点都是给定某些默认参数的构造⽅法⽐如默认制定拒绝策略是什么),这⾥就不贴代码讲了,比较简单。

/**
* ⽤给定的初始参数创建⼀个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
  if (corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0)
    throw new IllegalArgumentException();
  if (workQueue == null || threadFactory == null || handler == null)
    throw new NullPointerException();
  this.corePoolSize = corePoolSize;
  this.maximumPoolSize = maximumPoolSize;
  this.workQueue = workQueue;
  this.keepAliveTime = unit.toNanos(keepAliveTime);
  this.threadFactory = threadFactory;
  this.handler = handler;
}

下⾯这些对创建 ⾮常重要,在后⾯使⽤线程池的过程中你⼀定会⽤到!所以,务必拿着⼩本本记清楚。

ThreadPoolExecutor 构造函数重要参数分析

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 核⼼线程数线程数定义了最⼩可以同时运⾏的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运⾏的线程数量变为最⼤线程数。
  • workQueue : 当新任务来的时候会先判断当前运⾏的线程数量是否达到核⼼线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor 其他常⻅参数:

  • keepAliveTime :当线程池中的线程数量⼤于 corePoolSize 的时候,如果这时没有新的任务提交,核⼼线程外的线程不会⽴即销毁,⽽是会等待,直到等待的时间超过了keepAliveTime 才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会⽤到。
  • handler :饱和策略。关于饱和策略下⾯单独介绍⼀下。

ThreadPoolExecutor 饱和策略

ThreadPoolExecutor 饱和策略定义:

如果当前同时运⾏的线程数量达到最⼤线程数量并且队列也已经被放满了任时, ThreadPoolTaskExecutor 定义⼀些策略:

  • ThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException 来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy :调⽤执⾏⾃⼰的线程运⾏任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应⽤程序可以承受此延迟并且你不能任务丢弃任何⼀个任务请求的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy : 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。

举个例⼦: Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 饱和策略的话来配置线程池的时候默认使⽤的是 ThreadPoolExecutor.AbortPolicy 。在默认情况下, ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应⽤程序,建议使用ThreadPoolExecutor.CallerRunsPolicy 。当最⼤池被填满时,此策略为我们提供可伸缩队列。(这个直接查看 ThreadPoolExecutor 的构造函数源码就可以看出,⽐᫾简单的原因,这⾥就不贴代码了)

6.⼀个简单的线程池Demo: Runnable + ThreadPoolExecutor

为了让⼤家更清楚上⾯的⾯试题中的⼀些概念,我写了⼀个简单的线程池 Demo。

⾸先创建⼀个 Runnable 接⼝的实现类(当然也可以是 Callable 接⼝,我们上⾯也说了两者的区别。)

MyRunnable.java

import java.util.Date;
/**
* 这是⼀个简单的Runnable类,需要⼤约5秒钟来执⾏其任务。
* @author shuang.kou
*/
public class MyRunnable implements Runnable
{
private String command;
public MyRunnable(String s)
{
this.command = s;
}@
Override
public void run()
{
System.out.println(Thread.currentThread().getName() + " Start.
Time = " + new Date());
processCommand(); System.out.println(Thread.currentThread().getName() + " End.
Time = " + new Date());
}
private void processCommand()
{
try
{
Thread.sleep(5000);
}
catch(InterruptedException e)
{
e.printStackTrace();
}
}@
Override public String toString()
{
return this.command;
}
}

编写测试程序,我们这⾥以阿⾥巴巴推荐的使⽤ ThreadPoolExecutor 构造函数⾃定义参数的⽅式来创建线程池。

ThreadPoolExecutorDemo.java

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorDemo
{
private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final Long KEEP_ALIVE_TIME = 1 L;
public static void main(String[] args)
{
//使⽤阿⾥巴巴推荐的创建线程池的⽅式
//通过ThreadPoolExecutor构造函数⾃定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue < > (QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy());
for(int i = 0; i < 10; i++)
{
//创建WorkerThread对象(WorkerThread类实现了Runnable 接⼝)
Runnable worker = new MyRunnable("" + i);
//执⾏Runnable
executor.execute(worker);
}
//终⽌线程池
executor.shutdown();
while(!executor.isTerminated())
{}
System.out.println("Finished all threads");
}
}

可以看到我们上⾯的代码指定了:

  1. corePoolSize : 核⼼线程数为 5。
  2. maximumPoolSize :最⼤线程数 10
  3. keepAliveTime : 等待时间为 1L。
  4. unit : 等待时间的单位为 TimeUnit.SECONDS。
  5. workQueue :任务队列为 ArrayBlockingQueue ,并且容量为 100;
  6. handler :饱和策略为 CallerRunsPolicy 。

Output:

pool-1-thread-2 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-5 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-4 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-1 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-3 Start. Time = Tue Nov 12 20:59:44 CST 2019
pool-1-thread-5 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-3 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-2 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-4 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-1 End. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-2 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-1 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-4 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-3 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-5 Start. Time = Tue Nov 12 20:59:49 CST 2019
pool-1-thread-2 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-3 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-4 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-5 End. Time = Tue Nov 12 20:59:54 CST 2019
pool-1-thread-1 End. Time = Tue Nov 12 20:59:54 CST 2019

7.线程池原理分析

承接 4.6 节,我们通过代码输出结果可以看出:线程池每次会同时执⾏ 5 个任务,这 5 个任务执⾏完之后,剩余的 5 个任务才会被执⾏。 ⼤家可以先通过上⾯讲解的内容,分析⼀下到底是咋回事?(⾃⼰独⽴思考⼀会)

为了搞懂线程池的原理,我们需要⾸先分析⼀下 execute ⽅法。在 4.6 节中的 Demo 中我们使⽤executor.execute(worker) 来提交⼀个任务到线程池中去,这个⽅法⾮常重要,下⾯我们来看看它的源码:

// 存放线程池的运⾏状态 (runState) 和线程池内有效线程的数量(workerCount)
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static int workerCountOf(int c)
{
  return c & CAPACITY;
}
private final BlockingQueue < Runnable > workQueue;
public void execute(Runnable command)
{
   // 如果任务为null,则抛出异常。
   if(command WX null) throw new NullPointerException();
   // ctl 中保存的线程池当前的⼀些状态信息
   int c = ctl.get();
   // 下⾯会涉及到 3 步 操作
   // 1.⾸先判断当前线程池中之⾏的任务数量是否⼩于 corePoolSize
   // 如果⼩于的话,通过addWorker(command, true)新建⼀个线程,并将任务(command) 添加到该线程中; 然后, 启动该线程从⽽ 执⾏ 任务。
   if(workerCountOf(c) < corePoolSize)
   {
     if(addWorker(command, true)) return;
     c = ctl.get();
   }
// 2.如果当前之⾏的任务数量⼤于等于 corePoolSize 的时候就会⾛到这⾥
// 通过 isRunning ⽅法判断线程池状态,线程池处于 RUNNING 状态才会被并且队列可以加⼊ 任务, 该任务才会被加⼊ 进去
if(isRunning(c) && workQueue.offer(command))
{
  int recheck = ctl.get();
  // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务, 并尝试判断线程是否全部执⾏ 完毕。 同时执⾏ 拒绝策略。
  if(!isRunning(recheck) && remove(command)) reject(command);
  // 如果当前线程池为空就新创建⼀个线程并执⾏。
  else if(workerCountOf(recheck) WX 0) addWorker(null, false);
  }
  //3. 通过addWorker(command, false)新建⼀个线程,并将任务(command) 添加到该线程中; 然后, 启动该线程从⽽ 执⾏ 任务。
  //如果addWorker(command, false)执⾏失败,则通过reject()执⾏相应的拒绝策略的内容。
  else if(!addWorker(command, false)) reject(command);
}

通过下图可以更好的对上⾯这 3 步做⼀个展示

2024010603021121

我们在代码中模拟了 10 个任务,我们配置的核⼼线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执⾏,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之⾏完成后,才会之⾏剩下的 5 个任务。

15.Atomic 原⼦类

1.介绍⼀下Atomic 原⼦类

Atomic 翻译成中⽂是原⼦的意思。在化学上,我们知道原⼦是构成⼀般物质的最⼩单位,在化学反应中是不可分割的。在我们这⾥ Atomic 是指⼀个操作是不可中断的。即使是在多个线程⼀起执⾏的时候,⼀个操作⼀旦开始,就不会被其他线程⼲扰。所以,所谓原⼦类说简单点就是具有原⼦/原⼦操作特征的类。

并发包 java.util.concurrent 的原⼦类都存放在 java.util.concurrent.atomic 下,如下图所示。

2024010603043851

2.JUC 包中的原⼦类是哪4类?

这里我直接贴上图片

2024010603062956

3.讲讲 AtomicInteger 的使⽤

AtomicInteger 类常⽤⽅法

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并⾃增
public final int getAndDecrement() //获取当前的值,并⾃减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输⼊的数值等于预期值,则以原⼦⽅式将该值设置为输⼊值(update)
public final void lazySet(int newValue)//最终设置为newValue,使⽤ lazySet设置之后可能导致其他线程在之后的⼀⼩段时间内还是可以读到旧的值。

AtomicInteger 类的使⽤示例

使⽤ AtomicInteger 之后,不⽤对 increment() ⽅法加锁也可以保证线程安全。

class AtomicIntegerTest {
  private AtomicInteger count = new AtomicInteger();
  //使⽤AtomicInteger之后,不需要对该⽅法加锁,也可以实现线程安全。
  public void increment() {
    count.incrementAndGet();
  }

  public int getCount() {
    return count.get();
  }
}

4.能不能给我简单介绍⼀下 AtomicInteger 类的原理

AtomicInteger 线程安全原理简单分析,AtomicInteger 类的部分源码:

// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作⽤)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
  try {
    valueOffset = unsafe.objectFieldOffset
    (AtomicInteger.class.getDeclaredField("value"));
  } catch (Exception ex) { throw new Error(ex); }
 }
private volatile int value;

AtomicInteger 类主要利⽤ CAS (compare and swap) + volatile 和 native ⽅法来保证原⼦操作,从⽽避免 synchronized 的⾼开销,执⾏效率⼤为提升。

CAS的原理是拿期望的值和原本的⼀个值作比较,如果相同则更新成新的值。UnSafe 类的objectFieldOffset() ⽅法是⼀个本地⽅法,这个⽅法是⽤来拿到“原来的值”的内存地址,返回值是valueOffset。另外 value 是⼀个volatile变量,在内存中可⻅,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

16.AQS

1.AQS 介绍

AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下⾯。

2024010603104978

AQS是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,⽐如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们⾃⼰也能利⽤AQS⾮常轻松容易地构造出符合我们⾃⼰需求的同步器。

2.AQS 原理分析

AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。

CLH(Craig,Landin,and Hagersten)队列是⼀个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成⼀个CLH锁队列的⼀个结点(Node)来实现锁的分配。

看个AQS(AbstractQueuedSynchronizer)原理图:

2024010603141717

AQS使⽤⼀个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队⼯作。AQS使⽤CAS对该同步状态进⾏原⼦操作实现对其值的修改。

private volatile int state;//共享变量,使⽤volatile修饰保证线程可⻅性

状态信息通过protected类型的getState,setState,compareAndSetState进⾏操作

//返回同步状态的当前值
protected final int getState() { 
  return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
  state = newState;
}
//原⼦地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
  return unsafe.compareAndSwapInt(this, stateOffset, expect,update);
}

AQS 对资源的共享⽅式

  • Exclusive(独占):只有⼀个线程能执⾏,如ReentrantLock。⼜可分为公平锁和⾮公平锁:公平锁:按照线程在队列中的排队顺序,先到者先拿到锁,⾮公平锁:当线程要获取锁时,⽆视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执⾏,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后⾯讲到。

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某⼀资源进⾏读。

不同的⾃定义同步器争⽤共享资源的⽅式也不同。⾃定义同步器在实现时只需要实现共享资源 state的获取与释放⽅式即可,⾄于具体线程等待队列的维护(如获取资源失败⼊队/唤醒出队等),AQS已经在顶层实现好了。

AQS底层使⽤了模板⽅法模式

同步器的设计是基于模板⽅法模式的,如果需要⾃定义同步器⼀般的⽅式是这样(模板⽅法模式很经典的⼀个应⽤):

  1. 使⽤者继承AbstractQueuedSynchronizer并重写指定的⽅法。(这些重写⽅法很简单,⽆⾮是对于共享资源state的获取和释放)
  2. 将AQS组合在⾃定义同步组件的实现中,并调⽤其模板⽅法,⽽这些模板⽅法会调⽤使⽤者重写的⽅法。

这和我们以往通过实现接⼝的⽅式有很⼤区别,这是模板⽅法模式很经典的⼀个运⽤。AQS使⽤了模板⽅法模式,⾃定义同步器时需要重写下⾯⼏个AQS提供的模板⽅法:

isHeldExclusively()//该线程是否正在独占资源。只有⽤到condition才需要去实现它。
tryAcquire(int)//独占⽅式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占⽅式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享⽅式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可⽤资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享⽅式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个⽅法都抛出 UnsupportedOperationException 。 这些⽅法的实现必须是内部线程安全的,并且通常应该简短⽽不是阻塞。AQS类中的其他⽅法都是final ,所以⽆法被其他类使⽤,只有这⼏个⽅法可以被其他类使⽤。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调⽤tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为⽌,其它线程才有机会获取该锁。当然,释放锁之前,A线程⾃⼰是可以重复获取此锁的(state会累加),这就是可重⼊的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个⼦线程去执⾏,state也初始化为N(注意N要与线程个数⼀致)。这N个⼦线程是并⾏执⾏的,每个⼦线程执⾏完后countDown()⼀次,state会CAS(Compare andSwap)减1。等到所有⼦线程都执⾏完后(即state=0),会unpark()主调⽤线程,然后主调⽤线程就会从await()函数返回,继续后余动作。

⼀般来说,⾃定义同步器要么是独占⽅法,要么是共享⽅式,他们也只需实现 tryAcquiretryRelease 、 tryAcquireShared-tryReleaseShared 中的⼀种即可。但AQS也⽀持⾃定义同步器同时实现独占和共享两种⽅式,如ReentrantReadWriteLock 。

3.AQS 组件总结

  • Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是⼀次只允许⼀个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch (倒计时器): CountDownLatch是⼀个同步⼯具类,⽤来协调多个线程之间的同步。这个⼯具通常⽤来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执⾏。
  • CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch ⾮常类似,它也可以实现线程间的技术等待,但是它的功能⽐ CountDownLatch 更加复杂和强⼤。主要应⽤场景和CountDownLatch 类似。CyclicBarrier 的字⾯意思是可循环使⽤(Cyclic)的屏障(Barrier)。它要做的事情是,让⼀组线程到达⼀个屏障(也可以叫同步点)时被阻塞,直到最后⼀个线程到达屏障时,屏障才会开⻔,所有被屏障拦截的线程才会继续⼲活。CyclicBarrier默认的构造⽅法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调⽤await()⽅法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

2024010603195129

作者:Hardy

链接:https://bbyycc.com/stne/4171.html

声明:如无特别声明本文即为原创文章仅代表个人观点,版权归《屿川博客》作者所有,欢迎转载,转载请保留原文链接。

Like (0)
Donate 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
Previous 2024年1月5日 上午11:30
Next 2024年1月6日 上午9:55

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

TOP