Java多线程面试准备学习资料

多线程

写在前面:最近要面试几个年限比我多很多的大佬,所以总结了多线程可能问到的问题以及到底原理是什么。从网上找了好多详细的资料,仅自用。

1 多线程的实现方式有几种?

  • 继承Thread类;
  • 实现Runnable接口方式实现多线程 ;
  • 实现callable接口通过FutureTask包装器来创建Thread线程;
  • 通过线程池创建线程,使用线程池接口ExecutorService结合Callable、Future实现有返回结果的多线程。

前面两种可以归结为一类:无返回值,原因很简单,通过重写run方法,run方式的返回值是void,所以没有办法返回结果
后面两种可以归结成一类:有返回值,通过Callable接口,就要实现call方法,这个方法的返回值是Object,所以返回的结果可以放在Object对象中

1.1 继承Thread类

public class ThreadDemo01 extends Thread{
    public ThreadDemo01(){
        //编写子类的构造方法,可缺省
    }
    public void run(){
        //编写自己的线程代码
        System.out.println(Thread.currentThread().getName());
    }
    public static void main(String[] args){ 
        ThreadDemo01 threadDemo01 = new ThreadDemo01(); 
        threadDemo01.setName("我是自定义的线程1");
        threadDemo01.start();       
        System.out.println(Thread.currentThread().toString());  
    }
}

程序结果:
Thread[main,5,main]
我是自定义的线程1

1.2 实现Runnable接口方式

通过实现Runnable接口,实现run方法,接口的实现类的实例作为Thread的target作为参数传入带参的Thread构造函数,通过调用start()方法启动线程:

public class ThreadDemo02 {

    public static void main(String[] args){ 
        System.out.println(Thread.currentThread().getName());
        Thread t1 = new Thread(new MyThread());
        t1.start(); 
    }
}

class MyThread implements Runnable{
    @Override
    public void run() {
        // TODO Auto-generated method stub
        System.out.println(Thread.currentThread().getName()+"-->我是通过实现接口的线程实现方式!");
    }   
}

程序运行结果:
main
Thread-0–>我是通过实现接口的线程实现方式。

1.3 通过Callable和FutureTask创建线程

a:创建Callable接口的实现类 ,并实现Call方法
b:创建Callable实现类的实现,使用FutureTask类包装Callable对象,该FutureTask对象封装了Callable对象的Call方法的返回值
c:使用FutureTask对象作为Thread对象的target创建并启动线程
d:调用FutureTask对象的get()来获取子线程执行结束的返回值

public class ThreadDemo03 {

    /**
     * @param args
     */
    public static void main(String[] args) {
        // TODO Auto-generated method stub

        Callable<Object> oneCallable = new Tickets<Object>();
        FutureTask<Object> oneTask = new FutureTask<Object>(oneCallable);

        Thread t = new Thread(oneTask);

        System.out.println(Thread.currentThread().getName());

        t.start();

    }

}

class Tickets<Object> implements Callable<Object>{

    //重写call方法
    @Override
    public Object call() throws Exception {
        // TODO Auto-generated method stub
        System.out.println(Thread.currentThread().getName()+"-->我是通过实现Callable接口通过FutureTask包装器来实现的线程");
        return null;
    }   
}

程序运行结果:
main
Thread-0–>我是通过实现Callable接口通过FutureTask包装器来实现的线程

1.4 通过线程池创建线程

public class ThreadDemo05{

    private static int POOL_NUM = 10;     //线程池数量

    /**
     * @param args
     * @throws InterruptedException 
     */
    public static void main(String[] args) throws InterruptedException {
        // TODO Auto-generated method stub
        ExecutorService executorService = Executors.newFixedThreadPool(5);  
        for(int i = 0; i<POOL_NUM; i++)  
        {  
            RunnableThread thread = new RunnableThread();

            //Thread.sleep(1000);
            executorService.execute(thread);  
        }
        //关闭线程池
        executorService.shutdown(); 
    }   

}

class RunnableThread implements Runnable  
{     
    @Override
    public void run()  
    {  
        System.out.println("通过线程池方式创建的线程:" + Thread.currentThread().getName() + " ");  

    }  
}  

程序运行结果:
通过线程池方式创建的线程:pool-1-thread-3
通过线程池方式创建的线程:pool-1-thread-4
通过线程池方式创建的线程:pool-1-thread-1
通过线程池方式创建的线程:pool-1-thread-5
通过线程池方式创建的线程:pool-1-thread-2
通过线程池方式创建的线程:pool-1-thread-5
通过线程池方式创建的线程:pool-1-thread-1
通过线程池方式创建的线程:pool-1-thread-4
通过线程池方式创建的线程:pool-1-thread-3
通过线程池方式创建的线程:pool-1-thread-2

ExecutorService、Callable都是属于Executor框架。返回结果的线程是在JDK1.5中引入的新特征,还有Future接口也是属于这个框架,有了这种特征得到返回值就很方便了。
通过分析可以知道,他同样也是实现了Callable接口,实现了Call方法,所以有返回值。这也就是正好符合了前面所说的两种分类

执行Callable任务后,可以获取一个Future的对象,在该对象上调用get就可以获取到Callable任务返回的Object了。get方法是阻塞的,即:线程无返回结果,get方法会一直等待。

1.5 Executors类

提供了一系列工厂方法用于创建线程池,返回的线程池都实现了ExecutorService接口。

  • public static ExecutorService newFixedThreadPool(int nThreads)
    创建固定数目线程的线程池。
  • public static ExecutorService newCachedThreadPool()
    创建一个可缓存的线程池,调用execute 将重用以前构造的线程(如果线程可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程。
  • public static ExecutorService newSingleThreadExecutor()
    创建一个单线程化的Executor。
  • public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)
    创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。
  • ExecutoreService提供了submit()方法,传递一个Callable,或Runnable,返回Future。如果Executor后台线程池还没有完成Callable的计算,这调用返回Future对象的get()方法,会阻塞直到计算完成。

2 为什么禁止使用Executors创建线程池?

通过阅读本章,将会涉及到以下几点:

  • 线程池的定义
  • Executors创建线程池的几种方式
  • ThreadPoolExecutor对象
  • 线程池执行任务逻辑和线程池参数的关系
  • Executors创建返回ThreadPoolExecutor对象
  • OOM异常测试
  • 如何定义线程池参数

2.1 线程池的定义

管理一组工作线程。通过线程池复用线程有以下几点优点:

  • 减少资源创建 => 减少内存开销,创建线程占用内存
  • 降低系统开销 => 创建线程需要时间,会延迟处理的请求
  • 提高系统稳定性 => 避免无限创建线程引起的OutOfMemoryError【OOM】

2.2 Executors创建线程池的方式

根据返回的对象类型创建线程池可以分为三类:

  • 创建返回ThreadPoolExecutor对象
  • 创建返回ScheduleThreadPoolExecutor对象
  • 创建返回ForkJoinPool对象

本次主要针对ThreadPoolExecutor对象进行讨论

2.3 ThreadPoolExecutor对象

在介绍Executors创建线程池方法前先介绍一下ThreadPoolExecutor,因为这些创建线程池的静态方法都是返回ThreadPoolExecutor对象,和我们手动创建ThreadPoolExecutor对象的区别就是我们不需要自己传构造函数的参数。ThreadPoolExecutor的构造函数共有四个,但最终调用的都是同一个:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

构造函数参数说明:

  • corePoolSize => 线程池核心线程数量

  • maximumPoolSize => 线程池最大数量

  • keepAliveTime => 空闲线程存活时间

  • unit => 时间单位

  • workQueue => 线程池所使用的缓冲队列

  • threadFactory => 线程池创建线程使用的工厂

  • handler => 线程池对拒绝任务的处理策略

2.4 线程池执行任务逻辑和线程池参数的关系

在这里插入图片描述

执行逻辑:

  • 判断核心线程数是否已满,核心线程数大小和corePoolSize参数有关,未满则创建线程执行任务
  • 若核心线程池已满,判断队列是否满,队列是否满和workQueue参数有关,若未满则加入队列中
  • 若队列已满,判断线程池是否已满,线程池是否已满和maximumPoolSize参数有关,若未满创建线程执行任务
  • 若线程池已满,则采用拒绝策略处理无法执执行的任务,拒绝策略和handler参数有关

2.5 Executors创建返回ThreadPoolExecutor对象

Executors创建返回ThreadPoolExecutor对象的方法共有三种:

  • Executors#newCachedThreadPool => 创建可缓存的线程池
  • Executors#newSingleThreadExecutor => 创建单线程的线程池
  • Executors#newFixedThreadPool => 创建固定长度的线程池
2.5.1 Executors#newCachedThreadPool方法
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

CachedThreadPool是一个根据需要创建新线程的线程池

  • corePoolSize => 0,核心线程池的数量为0
  • maximumPoolSize => Integer.MAX_VALUE,可以认为最大线程数是无限的
  • keepAliveTime => 60L
  • unit => 秒
  • workQueue => SynchronousQueue

当一个任务提交时,corePoolSize为0不创建核心线程,SynchronousQueue是一个不存储元素的队列,可以理解为队里永远是满的,因此最终会创建非核心线程来执行任务。对于非核心线程空闲60s时将被回收。因为Integer.MAX_VALUE非常大,可以认为是可以无限创建线程的,在资源有限的情况下容易引起OOM异常。

2.5.2 Executors#newSingleThreadExecutor方法
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

SingleThreadExecutor是单线程线程池,只有一个核心线程

  • corePoolSize => 1,核心线程池的数量为1
  • maximumPoolSize => 1,只可以创建一个非核心线程
  • keepAliveTime => 0L
  • unit => 毫秒
  • workQueue => LinkedBlockingQueue

当一个任务提交时,首先会创建一个核心线程来执行任务,如果超过核心线程的数量,将会放入队列中,因为LinkedBlockingQueue是长度为Integer.MAX_VALUE的队列,可以认为是无界队列,因此往队列中可以插入无限多的任务,在资源有限的时候容易引起OOM异常,同时因为无界队列,maximumPoolSize和keepAliveTime参数将无效,压根就不会创建非核心线程。

2.5.3 Executors#newFixedThreadPool方法
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

FixedThreadPool是固定核心线程的线程池,固定核心线程数由用户传入

  • corePoolSize => 1,核心线程池的数量为1
  • maximumPoolSize => 1,只可以创建一个非核心线程
  • keepAliveTime => 0L
  • unit => 毫秒
  • workQueue => LinkedBlockingQueue

它和SingleThreadExecutor类似,唯一的区别就是核心线程数不同,并且由于使用的是LinkedBlockingQueue,在资源有限的时候容易引起OOM异常。

2.5.4 总结
  • FixedThreadPool和SingleThreadExecutor => 允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而引起OOM异常
  • CachedThreadPool => 允许创建的线程数为Integer.MAX_VALUE,可能会创建大量的线程,从而引起OOM异常

2.6 OOM异常测试

理论上会出现OOM异常,验证之前的说法: 测试类= >TaskTest.java

public class TaskTest {
    public static void main(String[] args) {
        ExecutorService es = Executors.newCachedThreadPool();
        int i = 0;
        while (true) {
            es.submit(new Task(i++));
        }
    }
}

使用Executors创建的CachedThreadPool,往线程池中无限添加线程
在启动测试类之前先将JVM内存调整小一点,不然很容易将电脑跑出问题,在idea里:Run -> Edit Configurations在这里插入图片描述

JVM参数说明:

  • -Xms10M => Java Heap内存初始化值
  • -Xmx10M => Java Heap内存最大值

运行结果:

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
Disconnected from the target VM, address: '127.0.0.1:60416', transport: 'socket'

创建到3w多个线程的时候开始报OOM错误
另外两个线程池就不做测试了,测试方法一致,只是创建的线程池不一样

2.7 如何定义线程池参数

  • CPU密集型 => 线程池的大小推荐为CPU数量 + 1,CPU数量可以根据Runtime.availableProcessors方法获取
  • IO密集型 => CPU数量 * CPU利用率 * (1 + 线程等待时间/线程CPU时间)
  • 混合型 => 将任务分为CPU密集型和IO密集型,然后分别使用不同的线程池去处理,从而使每个线程池可以根据各自的工作负载来调整
  • 阻塞队列 => 推荐使用有界队列,有界队列有助于避免资源耗尽的情况发生
  • 拒绝策略 => 默认采用的是AbortPolicy拒绝策略,直接在程序中抛出RejectedExecutionException异常【因为是运行时异常,不强制catch】,这种处理方式不够优雅。处理拒绝策略有以下几种比较推荐:
    • 1、在程序中捕获RejectedExecutionException异常,在捕获异常中对任务进行处理。针对默认拒绝策略
    • 2、使用CallerRunsPolicy拒绝策略,该策略会将任务交给调用execute的线程执行【一般为主线程】,此时主线程将在一段时间内不能提交任何任务,从而使工作线程处理正在执行的任务。此时提交的线程将被保存在TCP队列中,TCP队列满将会影响客户端,这是一种平缓的性能降低
    • 3、自定义拒绝策略,只需要实现RejectedExecutionHandler接口即可
    • 4、如果任务不是特别重要,使用DiscardPolicy和DiscardOldestPolicy拒绝策略将任务丢弃也是可以的

如果使用Executors的静态方法创建ThreadPoolExecutor对象,可以通过使用Semaphore对任务的执行进行限流也可以避免出现OOM异常。

3 核心线程的参数

3.1 参数介绍

corePoolSize(核心线程数)
  • (1)核心线程会一直存在,即使没有任务执行;
  • (2)当线程数小于核心线程数的时候,即使有空闲线程,也会一直创建线程直到达到核心线程数;
  • (3)设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭。
queueCapacity(任务队列容量)

也叫阻塞队列,当核心线程都在运行,此时再有任务进来,会进入任务队列,排队等待线程执行。

maxPoolSize(最大线程数)
  • (1)线程池里允许存在的最大线程数量;
  • (2)当任务队列已满,且线程数量大于等于核心线程数时,会创建新的线程执行任务;
  • (3)线程池里允许存在的最大线程数量。当任务队列已满,且线程数量大于等于核心线程数时,会创建新的线程执行任务。
keepAliveTime(线程空闲时间)
  • (1)当线程空闲时间达到keepAliveTime时,线程会退出(关闭),直到线程数等于核心线程数;
  • (2)如果设置了allowCoreThreadTimeout=true,则线程会退出直到线程数等于零。
allowCoreThreadTimeout(允许核心线程超时)
rejectedExecutionHandler(任务拒绝处理器)
  • (1)当线程数量达到最大线程数,且任务队列已满时,会拒绝任务;
  • (2)调用线程池shutdown()方法后,会等待执行完线程池的任务之后,再shutdown()。如果在调用了shutdown()方法和线程池真正shutdown()之间提交任务,会拒绝新任务。

3.2 线程池参数默认值

  • corePoolSize = 1
  • queueCapacity = Integer.MAX_VALUE
  • maxPoolSize = Integer.MAX_VALUE
  • keepAliveTime = 60秒
  • allowCoreThreadTimeout = false
  • rejectedExecutionHandler = AbortPolicy()
  • ThreadPoolExecutor(线程池)执行顺序

当线程数小于核心线程数时,会一直创建线程直到线程数等于核心线程数;

当线程数等于核心线程数时,新加入的任务会被放到任务队列等待执行;

任务队列已满,又有新的任务时,会创建线程直到线程数量等于最大线程数;

线程数等于最大线程数,且任务队列已满时,新加入任务会被拒绝

4 一个线程进来之后可能经过的流程

在线程的生命周期中,它要经过 新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直"霸占"着CPU独自运行,所以CPU需要在多条线程之间切换,于是 线程状态也会多次在运行、阻塞之间切换。

同上3.2:

当线程数小于核心线程数时,会一直创建线程直到线程数等于核心线程数;

当线程数等于核心线程数时,新加入的任务会被放到任务队列等待执行;

任务队列已满,又有新的任务时,会创建线程直到线程数量等于最大线程数;

线程数等于最大线程数,且任务队列已满时,新加入任务会被拒绝

5拒绝策略有哪几种

5.1 池化设计思想

池话设计应该不是一个新名词。我们常见的如java线程池、jdbc连接池、redis连接池等就是这类设计的代表实现。这种设计会初始预设资源,解决的问题就是抵消每次获取资源的消耗,如创建线程的开销,获取远程连接的开销等。就好比你去食堂打饭,打饭的大妈会先把饭盛好几份放那里,你来了就直接拿着饭盒加菜即可,不用再临时又盛饭又打菜,效率就高了。除了初始化资源,池化设计还包括如下这些特征:池子的初始值、池子的活跃值、池子的最大值等,这些特征可以直接映射到java线程池和数据库连接池的成员属性中。

5.2 线程池触发拒绝策略的时机

和数据源连接池不一样,线程池除了初始大小和池子最大值,还多了一个阻塞队列来缓冲。数据源连接池一般请求的连接数超过连接池的最大值的时候就会触发拒绝策略,策略一般是阻塞等待设置的时间或者直接抛异常。而线程池的触发时机如下图:

在这里插入图片描述

如图,想要了解线程池什么时候触发拒绝粗略,需要明确上面三个参数的具体含义,是这三个参数总体协调的结果,而不是简单的超过最大线程数就会触发线程拒绝粗略,当提交的任务数大于corePoolSize时,会优先放到队列缓冲区,只有填满了缓冲区后,才会判断当前运行的任务是否大于maxPoolSize,小于时会新建线程处理。大于时就触发了拒绝策略,总结就是:**当前提交任务数大于(maxPoolSize + queueCapacity)**时就会触发线程池的拒绝策略了。

5.3 JDK内置4种线程池拒绝策略

拒绝策略接口定义

在分析JDK自带的线程池拒绝策略前,先看下JDK定义的 拒绝策略接口,如下:

public interface RejectedExecutionHandler {	
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);	
}

接口定义很明确,当触发拒绝策略时,线程池会调用你设置的具体的策略,将当前提交的任务以及线程池实例本身传递给你处理,具体作何处理,不同场景会有不同的考虑,下面看JDK为我们内置了哪些实现:

CallerRunsPolicy(调用者运行策略)
public static class CallerRunsPolicy implements RejectedExecutionHandler {	
    public CallerRunsPolicy() { }	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {	
        if (!e.isShutdown()) {	
            r.run();	
        }	
    }	
}

功能:当触发拒绝策略时,只要线程池没有关闭,就由提交任务的当前线程处理。

使用场景:一般在不允许失败的、对性能要求不高、并发量较小的场景下使用,因为线程池一般情况下不会关闭,也就是提交的任务一定会被运行,但是由于是调用者线程自己执行的,当多次提交任务时,就会阻塞后续任务执行,性能和效率自然就慢了。

AbortPolicy(中止策略)
public static class AbortPolicy implements RejectedExecutionHandler {	
    public AbortPolicy() { }	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {	
        throw new RejectedExecutionException("Task " + r.toString() +	
                                             " rejected from " +	
                                             e.toString());	
    }	
}

功能:当触发拒绝策略时,直接抛出拒绝执行的异常,中止策略的意思也就是打断当前执行流程

使用场景:这个就没有特殊的场景了,但是一点要正确处理抛出的异常。ThreadPoolExecutor中默认的策略就是AbortPolicy,ExecutorService接口的系列ThreadPoolExecutor因为都没有显示的设置拒绝策略,所以默认的都是这个。但是请注意,ExecutorService中的线程池实例队列都是无界的,也就是说把内存撑爆了都不会触发拒绝策略。当自己自定义线程池实例时,使用这个策略一定要处理好触发策略时抛的异常,因为他会打断当前的执行流程。

DiscardPolicy(丢弃策略)
public static class DiscardPolicy implements RejectedExecutionHandler {	
    public DiscardPolicy() { }	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {	
    }	
}

功能:直接静悄悄的丢弃这个任务,不触发任何动作

使用场景:如果你提交的任务无关紧要,你就可以使用它 。因为它就是个空实现,会悄无声息的吞噬你的的任务。所以这个策略基本上不用了。

DiscardOldestPolicy(弃老策略)
public static class DiscardOldestPolicy implements RejectedExecutionHandler {	
    public DiscardOldestPolicy() { }	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {	
        if (!e.isShutdown()) {	
            e.getQueue().poll();	
            e.execute(r);	
        }	
    }	
}

功能:如果线程池未关闭,就弹出队列头部的元素,然后尝试执行

使用场景:这个策略还是会丢弃任务,丢弃时也是毫无声息,但是特点是丢弃的是老的未执行的任务,而且是待执行优先级较高的任务。基于这个特性,我能想到的场景就是,发布消息,和修改消息,当消息发布出去后,还未执行,此时更新的消息又来了,这个时候未执行的消息的版本比现在提交的消息版本要低就可以被丢弃了。因为队列中还有可能存在消息版本更低的消息会排队执行,所以在真正处理消息的时候一定要做好消息的版本比较。

5.4 第三方实现的拒绝策略

dubbo中的线程拒绝策略
public class AbortPolicyWithReport extends ThreadPoolExecutor.AbortPolicy {	
    protected static final Logger logger = LoggerFactory.getLogger(AbortPolicyWithReport.class);	
    private final String threadName;	
    private final URL url;	
    private static volatile long lastPrintTime = 0;	
    private static Semaphore guard = new Semaphore(1);	
    public AbortPolicyWithReport(String threadName, URL url) {	
        this.threadName = threadName;	
        this.url = url;	
    }	
    @Override	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {	
        String msg = String.format("Thread pool is EXHAUSTED!" +	
                        " Thread Name: %s, Pool Size: %d (active: %d, core: %d, max: %d, largest: %d), Task: %d (completed: %d)," +	
                        " Executor status:(isShutdown:%s, isTerminated:%s, isTerminating:%s), in %s://%s:%d!",	
                threadName, e.getPoolSize(), e.getActiveCount(), e.getCorePoolSize(), e.getMaximumPoolSize(), e.getLargestPoolSize(),	
                e.getTaskCount(), e.getCompletedTaskCount(), e.isShutdown(), e.isTerminated(), e.isTerminating(),	
                url.getProtocol(), url.getIp(), url.getPort());	
        logger.warn(msg);	
        dumpJStack();	
        throw new RejectedExecutionException(msg);	
    }	
    private void dumpJStack() {	
       //省略实现	
    }	
}

可以看到,当dubbo的工作线程触发了线程拒绝后,主要做了三个事情,原则就是尽量让使用者清楚触发线程拒绝策略的真实原因

  • 输出了一条警告级别的日志,日志内容为线程池的详细设置参数,以及线程池当前的状态,还有当前拒绝任务的一些详细信息。可以说,这条日志,使用dubbo的有过生产运维经验的或多或少是见过的,这个日志简直就是日志打印的典范,其他的日志打印的典范还有spring。得益于这么详细的日志,可以很容易定位到问题所在
  • 输出当前线程堆栈详情,这个太有用了,当你通过上面的日志信息还不能定位问题时,案发现场的dump线程上下文信息就是你发现问题的救命稻草,这个可以参考《dubbo线程池耗尽事件-“CyclicBarrier惹的祸”》
  • 继续抛出拒绝执行异常,使本次任务失败,这个继承了JDK默认拒绝策略的特性
Netty中的线程池拒绝策略
private static final class NewThreadRunsPolicy implements RejectedExecutionHandler {	
    NewThreadRunsPolicy() {	
        super();	
    }	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {	
        try {	
            final Thread t = new Thread(r, "Temporary task executor");	
            t.start();	
        } catch (Throwable e) {	
            throw new RejectedExecutionException(	
                    "Failed to start a new thread", e);	
        }	
    }	
}

Netty中的实现很像JDK中的CallerRunsPolicy,舍不得丢弃任务。不同的是,CallerRunsPolicy是直接在调用者线程执行的任务。而 Netty是新建了一个线程来处理的。所以,Netty的实现相较于调用者执行策略的使用面就可以扩展到支持高效率高性能的场景了。但是也要注意一点,Netty的实现里,在创建线程时未做任何的判断约束,也就是说只要系统还有资源就会创建新的线程来处理,直到new不出新的线程了,才会抛创建线程失败的异常。

activeMq中的线程池拒绝策略
new RejectedExecutionHandler() {	
                @Override	
                public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) {	
                    try {	
                        executor.getQueue().offer(r, 60, TimeUnit.SECONDS);	
                    } catch (InterruptedException e) {	
                        throw new RejectedExecutionException("Interrupted waiting for BrokerService.worker");	
                    }	
                    throw new RejectedExecutionException("Timed Out while attempting to enqueue Task.");	
                }	
            });
pinpoint中的线程池拒绝策略
public class RejectedExecutionHandlerChain implements RejectedExecutionHandler {	
    private final RejectedExecutionHandler[] handlerChain;	
    public static RejectedExecutionHandler build(List<RejectedExecutionHandler> chain) {	
        Objects.requireNonNull(chain, "handlerChain must not be null");	
        RejectedExecutionHandler[] handlerChain = chain.toArray(new RejectedExecutionHandler[0]);	
        return new RejectedExecutionHandlerChain(handlerChain);	
    }	
    private RejectedExecutionHandlerChain(RejectedExecutionHandler[] handlerChain) {	
        this.handlerChain = Objects.requireNonNull(handlerChain, "handlerChain must not be null");	
    }	
    @Override	
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {	
        for (RejectedExecutionHandler rejectedExecutionHandler : handlerChain) {	
            rejectedExecutionHandler.rejectedExecution(r, executor);	
        }	
    }	
}

pinpoint的拒绝策略实现很有特点,和其他的实现都不同。他定义了一个拒绝策略链,包装了一个拒绝策略列表,当触发拒绝策略时,会将策略链中的rejectedExecution依次执行一遍。

6 AQS实现逻辑

AQS原理
AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包

AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。

用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

**注意:AQS是自旋锁:**在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功

实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
AQS实现的具体方式如下:
在这里插入图片描述

如图示,AQS维护了一个volatile int state和一个FIFO线程等待队列,多线程争用资源被阻塞的时候就会进入这个队列。state就是共享资源,其访问方式有如下三种:
getState();``setState();``compareAndSetState();

AQS 定义了两种资源共享方式:
1.Exclusive:独占,只有一个线程能执行,如ReentrantLock
2.Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

不同的自定义的同步器争用共享资源的方式也不同。

AQS底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
  2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
    这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS已经在顶层实现好了。自定义同步器实现的时候主要实现下面几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。
注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。

以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
 在acquire() acquireShared()两种方式下,线程在等待队列中都是忽略中断的,acquireInterruptibly()/acquireSharedInterruptibly()是支持响应中断的。

AQS的简单应用
Mutex:不可重入互斥锁,锁资源(state)只有两种状态:0:未被锁定;1:锁定。

class Mutex implements Lock, java.io.Serializable {
    // 自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer {
        // 判断是否锁定状态
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 尝试获取资源,立即返回。成功则返回true,否则false。
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // 这里限定只能为1个量
            if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
                setExclusiveOwnerThread(Thread.currentThread());//设置为当前线程独占资源
                return true;
            }
            return false;
        }

        // 尝试释放资源,立即返回。成功则为true,否则false。
        protected boolean tryRelease(int releases) {
            assert releases == 1; // 限定为1个量
            if (getState() == 0)//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
                throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);//释放资源,放弃占有状态
            return true;
        }
    }

    // 真正同步类的实现都依赖继承于AQS的自定义同步器!
    private final Sync sync = new Sync();

    //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
    public void lock() {
        sync.acquire(1);
    }

    //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    //unlock<-->release。两者语文一样:释放资源。
    public void unlock() {
        sync.release(1);
    }

    //锁是否占有状态
    public boolean isLocked() {
        return sync.isHeldExclusively();
    }
}

同步类在实现时一般都将自定义同步器(sync)定义为内部类,供自己使用;而同步类自己(Mutex)则实现某个接口,对外服务。

7 AQS解析

AQS(AbstractQueuedSynchronizer)是Java众多锁以及并发工具的基础类,底层采用乐观锁,大量采用CAS操作保证其原子性,并且在并发冲突时,采用自旋方法重试。实现了轻量高效的获取锁。

7.1 AQS的关注点

ReentrantLock中使用到了AQS高并发组件,用它来维护锁的状态,这样就不需要利用操作系统来维护,减少了上下文切换。AQS中使用了CAS、自旋操作来提高性能。但是在线程过多的时候,还是会和操作系统打交道,挂起线程和唤醒线程两个上下文操作。

AQS在线程交替运行时,只需要借助CAS和自旋就可以完成加锁。

而synchronized在JDK1.6版本前是重量级锁,加锁的操作是涉及到操作系统进行互斥操作,就是会把当前线程挂起,然后操作系统进行互斥操作修改,由mutexLock来完成,之后才唤醒。操作系统来判断线程是否加锁,所以它是一个重量级操作。挂起、唤醒这两个操作进行了两次上下文切换,消耗CPU,降低性能。
总之一句话,重量级锁是需要依靠操作系统来实现互斥锁的,这导致大量上下文切换,消耗大量CPU,影响性能。

7.1.1 信号量

在AQS中,状态是由volatile state来表示。

private volatile int state;

该属性值表示锁的状态。state为0表示锁未被占用,state1表示锁被线程持有,而state大于1表示锁被重入。

而本文分析的是独占锁,那么同一时刻,锁只能被一个线程持有。

不仅需要记录锁的状态,还需要记录当前获取锁的线程,实现重入。可以通过来记录。

7.1.2 等待队列

等待队列采用悲观锁的思想,表示当前所等待的资源,状态或条件短时间内可能无法满足,而调用park方法(借助操作系统)来完成线程的阻塞。

在AQS中,队列时一个双端链表,将当前线程包装成某种类型的数据结构扔到等待队列中。

static final class Node {  
// 节点所代表的线程  
volatile Thread thread;    
// 双向链表,每个节点需要保存自己的前驱节点和后继节点的引用  
volatile Node prev;  
volatile Node next;  
// 线程所处的等待锁的状态,初始化时,该值为0。  
volatile int waitStatus;  
//队列中节点线程被取消
static final int CANCELLED =  1;
//节点将其前驱节点设置为-1,当前驱节点释放锁后,会自动唤醒该节点。  
static final int SIGNAL    = -1;  
//线程被重新包装为Node节点,并存入Condition队列中。
static final int CONDITION = -2;  
//共享锁唤醒风暴时,将0->PROPAGATE,表示被传播唤醒
static final int PROPAGATE = -3;  
// 该属性用于条件队列或者共享锁 。在Condition队列中,使用其作为指针。
Node nextWaiter;  
}  

一般在独占锁下,我们需要关注的就是下面几个参数:

  • thread:当前Node所代表的线程;
  • waitStatus:表示节点所处的等待状态;
  • prev next:节点的前驱和后继。
7.1.3 CAS操作

​ CAS采用乐观锁机制,保证操作的原子性。一般是改变状态或改变指针(引用)指向。在这里插入图片描述

7.1.4 总结

在AQS源码中:

  1. 锁属性

    //锁的状态
    private volatile int state;
    //当前持有锁的线程
    private transient Thread exclusiveOwnerThread;
    
  2. sync queue相关的属性

    //thread属性为null
    private transient volatile Node head; 
    private transient volatile Node tail; // 队尾,新入队的节点
    
  3. Node相关属性

    // 节点所代表的线程
    volatile Thread thread;
    
    // 双向链表,每个节点需要保存自己的前驱节点和后继节点的引用
    volatile Node prev;
    volatile Node next;
    
    // 线程所处的等待锁的状态,初始化时,该值为0
    volatile int waitStatus;
    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    

7.2 源码解析

ReentrantLock有公平锁和非公平锁两种实现,默认实现非公平锁。但是可配置为公平锁:

ReentrantLock lock=new ReentrantLock(true);

调用公平锁加锁逻辑:

final void lock() {  
    //开始加锁,将state修改为1
    acquire(1); 
}  

真正的加锁方法:

public final void acquire(int arg) {  
    if (!tryAcquire(arg) &&    
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))   
        selfInterrupt();    
}  
7.2.1 加锁的逻辑方法

只执行上述方法便可完成整个的加锁逻辑。而该方法中又包含下列四个方法的调用:

  1. tryAcquire(arg)
    该方法由继承AQS的子类实现,为获取锁的具体逻辑;
  2. addWaiter(Node.EXCLUSIVE)
    该方法由AQS实现,负责在获取锁失败后调用,将当前请求锁的线程包装成Node并且放到等待队列中,并返回该Node。
  3. acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
    该方法由AQS实现。针对上面加入到队列的Node不断尝试两种操作之一:
  • 若前驱节点是head节点的时候,尝试获取锁;
  • 调用park将当前线程挂起,线程阻塞。
  1. selfInterrupt
    该方法由AQS实现。恢复用户行为。
  • 用户在外界调用t1.interrupt()进行中断。
  • 线程在parkAndCheckInterrupt方法被唤醒之后。会调用Thread.interrupted();判断线程的中断标识,而该方法调用完毕会清除中断标识位。
  • 而AQS为了不改变用户标识。再次调用selfInterrupt恢复用户行为。
7.2.2 如何构建等待队列——addWaiter

我们使用ReentrantLock独占锁时,等待队列是延迟加载的。也就是说若是线程交替执行,那么借助信号量(状态)来保证。若是线程并发执行,就需要将阻塞线程放入到队列中。

//注意这个方法可能存在并发问题,mode为null(独占锁)。
private Node addWaiter(Node mode) {  
    Node node = new Node(Thread.currentThread(), mode);  
    Node pred = tail;  
    //队列已经存在
    if (pred != null) {  
       //新节点的前驱指针指向尾节点(可能造成尾分叉)
        node.prev = pred;  
       //保证原子性,只有一个才能成功
        if (compareAndSetTail(pred, node)) {  
            pred.next = node;  
            return node;  
        }  
    }  
    //队列不存在&&上面CAS失败的线程会进入enq方法自旋
    enq(node);  
    return node;  
}  
队列不存在的情况

在这里插入图片描述

注意,该方法处理CAS操作是原子性的,其他操作都存在并发冲突问题。

private Node enq(final Node node) {  
     for (;;) {  
         Node t = tail;  
          //初始化阻塞队列
         if (t == null) { // Must initialize  
             if (compareAndSetHead(new Node()))  
                 tail = head;  
         } else {  
            //自旋处理addWaiter中CAS加锁失败的线程
             node.prev = t;  
             if (compareAndSetTail(t, node)) {  
                 t.next = node;  
                 return t;  
             }  
         }  
     }  
 }  

该方法采用自旋+CAS。CAS是保证同一时刻只有一个线程能成功改变引用的指向。
在这里插入图片描述
根据上面的流程图,sync queue的创建过程。**head节点是new Node()产生的,即其中的属性为默认值。*也就是thread属性为null。也就是说*正在执行的线程也会在sync queue中占据头节点,但是节点中不会保存线程信息。
在这里插入图片描述

尾分叉问题:

上面已经说了,该方法是线程不安全的。

 //步骤1:可能多个节点的prev指针都指向尾结点,导致尾分叉
 node.prev = t;  
 //步骤2:但同一时刻,tail引用只会执行一个node。
 if (compareAndSetTail(t, node)) {  
    //步骤3:现在环境是线程安全,旧尾结点的后继指针指向新尾结点。
    t.next = node;  
    return t;  
  }  

在这里插入图片描述

执行完步骤2,但步骤3还未执行时,恰好有线程从头节点开始往后遍历。**此时(旧)尾结点中的next域还为null。**它是遍历不到新加进来的尾结点的。这显然是不合理的。

但此时步骤1是执行成功的,所以若是tail节点往前遍历,实际上是可以遍历到所有节点的,这也是为什么在AQS源码中,有时候常常会出现从尾结点开始逆向遍历链表的情况

那些“分叉”的节点,肯定会入队失败。那么继续自旋,等待所有的线程节点全部入队成功。

7.2.3 尝试获取锁——tryAcquire

根据标志位state,来判断锁是否被占用。此时可能锁未被占用,由于是公平锁,于是会去判断sync queue中是否有人在排队。

protected final boolean tryAcquire(int acquires) {  
    //获取当前线程
    final Thread current = Thread.currentThread();  
    //获取Lock对象的上锁情况,0-表示无线程持有;1-表示被线程持有;大于1-表示锁被重入
    int c = getState();  
    //若此刻无人占有锁
    if (c == 0) {  
        if (!hasQueuedPredecessors() &&    //判断队列中是否有前辈。若返回false代表没有,开始尝试加锁
            compareAndSetState(0, acquires)) {   //此刻队列中没有存在前辈,尝试加锁
            setExclusiveOwnerThread(current);   //将当前线程修改为持有锁的线程(后续判断可重入)
            return true;  
        }  
    }  
    //若是当前线程是持有锁的线程
    else if (current == getExclusiveOwnerThread()) {  
        //当前状态+1
        int nextc = c + acquires;  
        if (nextc < 0)  
            throw new Error("Maximum lock count exceeded");  
        setState(nextc);  
        return true;  
    }  
    //否则,代表加锁失败
    return false;  
}  

下面的方法返回false才会尝试加锁(该方法不具有原子性,可能会放行多个线程)。

//该方法不具有原子性,可能多个线程都觉得自己不需要排队,最终还是依靠外面
//条件上的CAS来保持其原子性。
public final boolean hasQueuedPredecessors() {  
    Node t = tail;   //尾节点
    Node h = head;   //头节点
    Node s;  
    return h != t &&  
        ((s = h.next) == null || s.thread != Thread.currentThread());  
} 

上述方法是判断队列中是否存在元素。可能存在以下几种情况:

  • 此时未维护队列【h和t指向null】,h!=t返回false,即无人排队;
  • 此时队列只有头节点(哑结点)【h和t都指向哑结点】,h!=t返回false,即无人排队;
  • 此时队列中存在2个以上的节点。若线程是头结点的后继节点线程(即处理正在办理业务的线程,进来的线程是第一个排队的线程)。那么s.thread != Thread.currentThread()返回false,即可是尝试加锁。
  • 队列存在2个以上节点,且进来的线程不是第一个排队的线程,那么该线程需要乖乖的排队。

当然该方法不是并发安全的方法,即可能存在多个线程觉得自己无需排队,最终还是依靠CAS来争夺锁。

 if (!hasQueuedPredecessors() &&  compareAndSetState(0, acquires)) {
     //线程安全   
     setExclusiveOwnerThread(current);  
     return true;  
 }  

同一时刻,只有一个线程可以成功改变state的状态。记录该线程为独占锁线程,一般后续可以重入。

没成功获取锁那么会调用2.2 中的方法,将该线程加入到阻塞队列中

7.2.4 阻塞线程——acquireQueued
  • 若执行到该方法,说明addWaiter方法已经成功将该线程包装为Node节点放到了队尾。
  • 在该方法中依旧尝试获取锁;
  • 再次获取锁失败后,会将其阻塞。
final boolean acquireQueued(final Node node, int arg) {  
    boolean failed = true;  
    try {  
        boolean interrupted = false;  
        for (;;) {  
            //获取node的前驱节点
            final Node p = node.predecessor();  
            //若前驱节点在办理业务,那么它将再次获取一次锁。
            if (p == head && tryAcquire(arg)) {  
                //获取锁成功,此处便是线程安全。
                //将自己设置为头节点,并将自己设置为哑节点
                setHead(node);  
                p.next = null; // help GC  
                failed = false;  
                return interrupted;  
            }  
            //获取锁失败,将自己挂起。
            if (shouldParkAfterFailedAcquire(p, node) &&  
                parkAndCheckInterrupt())  
                interrupted = true;  
        }  
    } finally {  
        if (failed)  
            cancelAcquire(node);  
    }  
}  

上述方法是自旋方法,而出口就是获取到锁。若线程获取不到锁,便会将自己阻塞。

//该方法时node线程获取锁成功后执行的,故是线程安全的。
private void setHead(Node node) {  
    head = node;  
    node.thread = null;  
    node.prev = null;  
}  

在这里插入图片描述

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {  
    //上一个节点的waitStatus
    int ws = pred.waitStatus;  
    //  Node.SIGNAL==-1
    if (ws == Node.SIGNAL)  
        return true;  
    //ws大于0,则说明该节点已经被取消了。
    if (ws > 0) {  
        do {  
            node.prev = pred = pred.prev;  
        } while (pred.waitStatus > 0);  
        pred.next = node;  
    } else {  
        //CAS变更ws的状态
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);  
    }  
    return false;  
}  

上述方法是加锁失败开始执行的。也就是一个线程决定挂起之前需要执行的操作。这里就用到了节点中的信号量waitStatus

  • 判断前驱节点waitStatus的值,会做出如下操作:

    • 1.1 前驱节点waitStatus若是-1,直接返回true。
    • 1.2 前驱节点waitStatus若大于0,证明前驱节点已被取消,那么在链表中删除前驱节点,直到node的前驱节点的waitStatus不大于0为止。然后返回false。
    • 1.3. 若前驱节点waitStatus等于0,使用CAS尝试改变前驱节点waitStatus状态,由0到-1,然后返回false。
  • 若是返回true,那么去阻塞该节点,若是返回false,那么继续自旋,继续上述过程,直至该方法返回true为止,方法返回true,便会执行下列方法,阻塞线程。

    private final boolean parkAndCheckInterrupt() {  
        //将线程挂起
        LockSupport.park(this);  
        //线程被唤起时,查看线程的中断标识(注意,查看完毕后,中断标识归位)
        return Thread.interrupted();  
    } 
    

    需要注意的是:当前节点在阻塞之前,会将前驱节点的waitStatus设置为-1,就可保证前驱节点在适当的时机唤醒自己。

附录

对象的CAS算法

在这里插入图片描述

开始我认为对象的CAS算法,实际上会是B对象去覆盖堆内存上的A对象,其实不然。比较交换的是引用。

//该方法是获取引用。而非堆上的内存。
static {  
    try {  
        valueOffset = unsafe.objectFieldOffset  
            (AtomicReference.class.getDeclaredField("value"));  
    } catch (Exception ex) { throw new Error(ex); }  
}  

7.3 加锁总结

  1. 因为AQS的等待队列是延迟加载,只有多个线程并发访问时,才会开始维护队列。
  2. 因为head节点中不包含thread属性的值,又被称为哑节点
  3. head是正在办理业务的节点,而他的后继节点是第一个排队节点。
尝试加锁过程
  1. 根据status判断当前锁是否被持有,若被持有,直接维护队列
  2. 若未被持有,判断当前队列是否有节点在排队,若有节点排队,直接维护队列
  3. 若无节点排队,则通过CAS修改锁状态标识,修改成功代表线程持有该锁;
  4. 使用exclusiveOwnerThread来保存持有锁的线程(解决线程重入)。
维护队列过程

最终线程的head节点为哑节点。后续线程被组装成node节点,维护在链表中。

线程阻塞过程
  1. 判断node节点是否为head节点的后续节点(第一个排队节点),若是的话,尝试获取锁。若获取到,将其设置为head节点,并将其设置为哑节点;
  2. 在阻塞前,会将自己的前驱节点的waitStatus设置为SIGNAL。以便可以唤醒自己。

8 读写锁是怎么实现的?

针对读多写少的场景,Java提供了另外一个实现Lock接口的读写锁ReentrantReadWriteLock(RRW),之前分析过ReentrantLock是一个独占锁,同一时间只允许一个线程访问。

而 RRW 允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。
读写锁内部维护了两个锁,一个是用于读操作的ReadLock,一个是用于写操作的 WriteLock。

读写锁遵守以下三条基本原则

  • 允许多个线程同时读共享变量;
  • 只允许一个线程写共享变量;
  • 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁如何实现

RRW也是基于AQS实现的,它的自定义同步器(继承自AQS)需要在同步状态state上维护多个读线程和一个写线程的状态。RRW的做法是使用高低位来实现一个整形控制两种状态,一个int占4个字节,一个字节8位。所以高16位表示读,低16位表示写。在这里插入图片描述

abstract static class Sync extends AbstractQueuedSynchronizer {

  static final int SHARED_SHIFT   = 16;

  // 10000000000000000(65536)
  static final int SHARED_UNIT    = (1 << SHARED_SHIFT);

  // 65535
  static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;

  //1111111111111111
  static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

  // 读锁(共享锁)的数量,只计算高16位的值
  static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }

  // 写锁(独占锁)的数量
  static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
 }


获取读锁

当线程获取读锁时,首先判断同步状态低16位,如果存在写锁,则获取锁失败,进入CLH队列阻塞,反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同步状态,获取成功更新同步锁为读状态。

 protected final int tryAcquireShared(int unused) {
           
  Thread current = Thread.currentThread();
  int c = getState();
  // 如果当前已经有写锁了,则获取失败
  if (exclusiveCount(c) != 0 &&
      getExclusiveOwnerThread() != current)
      return -1;
  // 获取读锁数量
  int r = sharedCount(c);

  // 非公平锁实现中readerShouldBlock()返回true表示CLH队列中有正在排队的写锁
  // CAS设置读锁的状态值
  if (!readerShouldBlock() &&
      r < MAX_COUNT &&
      compareAndSetState(c, c + SHARED_UNIT)) {

      // 省略记录获取readLock次数的代码

      return 1;
  }

  // 针对上面失败的条件进行再次处理
  return fullTryAcquireShared(current);
}

final int fullTryAcquireShared(Thread current) {
  
  // 无线循环
  for (;;) {
    int c = getState();
    if (exclusiveCount(c) != 0) {
      // 如果不是当前线程持有写锁,则进入CLH队列阻塞
      if (getExclusiveOwnerThread() != current)
        return -1;
    } 

    // 如果reader应该被阻塞
    else if (readerShouldBlock()) {
        // Make sure we're not acquiring read lock reentrantly
        if (firstReader == current) {
            // assert firstReaderHoldCount > 0;
        } else {
            if (rh == null) {
                rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current)) {
                    rh = readHolds.get();
                    if (rh.count == 0)
                        readHolds.remove();
                }
            }
            // 当前线程没有持有读锁,即不存在锁重入情况。则进入CLH队列阻塞
            if (rh.count == 0)
                return -1;
        }
    }

    // 共享锁的如果超出了限制
    if (sharedCount(c) == MAX_COUNT)
        throw new Error("Maximum lock count exceeded");

    // CAS设置状态值
    if (compareAndSetState(c, c + SHARED_UNIT)) {
      
      // 省略记录readLock次数的代码

      return 1;
    }
  }

}

SHARED_UNIT的值是65536,也就是说,当第一次获取读锁的后,state值就变成了65536
在公平锁的实现中当CLH队列中有排队的线程,readerShouldBlock()方法就会返回为true。非公平锁的实现中则是当CLH队列中存在等待获取写锁的线程就返回true

还需要注意的是获取读锁的时候,如果当前线程已经持有写锁,是仍然能获取读锁成功的。后面会提到锁的降级,如果你对那里的代码有疑问,可以在回过头来看看这里申请锁的代码。

释放读锁

protected final boolean tryReleaseShared(int unused) {
           
  for (;;) {
    int c = getState();
    // 减去65536
    int nextc = c - SHARED_UNIT;
    // 只有当state的值变成0才会真正的释放锁
    if (compareAndSetState(c, nextc))
        return nextc == 0;
}
}

释放锁时,state的值需要减去65536,因为当第一次获取读锁后,state值变成了65536

任何一个线程释放读锁的时候只有在state==0的时候才真正释放了锁,比如有100个线程获取了读锁,只有最后一个线程执行tryReleaseShared方法时才真正释放了锁,此时会唤醒CLH队列中的排队线程。

获取写锁

一个线程尝试获取写锁时,会先判断同步状态 state 是否为0。如果state 等于0,说明暂时没有其它线程获取锁;如果 state 不等于0,则说明有其它线程获取了锁。

此时再判断state的低16位(w)是否为0,如果w0,表示其他线程获取了读锁,此时进入CLH队列进行阻塞等待。

如果w不为0,则说明其他线程获取了写锁,此时需要判断获取了写锁的是不是当前线程,如果不是则进入CLH队列进行阻塞等待,如果获取了写锁的是当前线程,则判断当前线程获取写锁是否超过了最大次数,若超过,抛出异常。反之则更新同步状态。

// 获取写锁
protected final boolean tryAcquire(int acquires) {
           
  Thread current = Thread.currentThread();
  int c = getState();
  int w = exclusiveCount(c);

  // 判断state是否为0
  if (c != 0) {
      // 获取锁失败
      if (w == 0 || current != getExclusiveOwnerThread())
          return false;

      // 判断当前线程获取写锁是否超出了最大次数65535
      if (w + exclusiveCount(acquires) > MAX_COUNT)
          throw new Error("Maximum lock count exceeded");
      
      // 锁重入
      setState(c + acquires);
      return true;
  }
  // 非公平锁实现中writerShouldBlock()永远返回为false
  // CAS修改state的值
  if (writerShouldBlock() ||
      !compareAndSetState(c, c + acquires))
      return false;

  // CAS成功后,设置当前线程为拥有独占锁的线程
  setExclusiveOwnerThread(current);
  return true;
}

在公平锁的实现中当CLH队列中存在排队的线程,那么writerShouldBlock()方法就会返回为true,此时获取写锁的线程就会被阻塞。

释放写锁

释放写锁的逻辑比较简单

 protected final boolean tryRelease(int releases) {
  // 写锁是否被当前线程持有
  if (!isHeldExclusively())
      throw new IllegalMonitorStateException();
  
  int nextc = getState() - releases;
  boolean free = exclusiveCount(nextc) == 0;

  // 没有其他线程持有写锁
  if (free)
      setExclusiveOwnerThread(null);
  setState(nextc);
  return free;
}

锁的升级?

// 准备读缓存
readLock.lock();
try {
  v = map.get(key);
  if(v == null) {
    writeLock.lock();
    try {
      if(map.get(key) != null) {
        return map.get(key);
      }

      // 更新缓存代码,省略
    } finally {
      writeLock.unlock();
    }
  }
} finally {
  readLock.unlock();
}

锁的降级

虽然锁的升级不允许,但是锁的降级却是可以的。

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

ReadLock readLock = lock.readLock();

WriteLock writeLock = lock.writeLock();

Map<String, String> dataMap = new HashMap();

public void processCacheData() {
  readLock.lock();

  if(!cacheValid()) {

    // 释放读锁,因为不允许
    readLock.unlock();

    writeLock.lock();

    try {
      if(!cacheValid()) {
          dataMap.put("key", "think123");
      }

      // 降级为读锁
      readLock.lock();
    } finally {
        writeLock.unlock();
    }
  }

  try {
    // 仍然持有读锁
    System.out.println(dataMap);
  } finally {
      readLock.unlock();
  }
}

public boolean cacheValid() {
    return !dataMap.isEmpty();
}

RRW需要注意的问题

  • 在读取很多、写入很少的情况下,RRW 会使写入线程遭遇饥饿(Starvation)问题,也就是说写入线程会因迟迟无法竞争到锁而一直处于等待状态。
  • 写锁支持条件变量,读锁不支持。读锁调用newCondition() 会抛出UnsupportedOperationException 异常。

9 synchronized

9.1 synchronized的使用

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对 synchronized进行了各种优化之后,有些情况下它就并不那么重了,Java SE 1.6中为了减少获得锁和释放锁带来的 性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。通过 synchronized关键字来修饰在inc的方法上,看看执行结果。(可以自己尝试将synchronizrd去掉,看看结果得到的是不是1000)。

public class Demo{
    private static int count=0;
    public static void inc(){
        synchronized (Demo.class) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            count++; 
        }
    }
    public static void main(String[] args) throws InterruptedException {
        for(int i=0;i<1000;i++){
            new Thread(()->Demo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("运行结果"+count); 
    }
}

9.2 synchronized的三种应用方式

synchronized有三种方式来加锁,分别是

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

9.3 synchronized括号后面的对象

synchronized扩号后面的对象是一把锁,在java中任意一个对象都可以成为锁。简单来说,我们把object比喻是一 个key,拥有这个key的线程才能执行这个方法,拿到这个key以后在执行方法过程中,这个key是随身携带的并且只有一把。如果后续的线程想访问当前方法,因为没有key所以不能访问只能在门口等着,等之前的线程把key放回去。所以,synchronized锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。

9.4 synchronized的字节码指令

通过javap -v 来查看对应代码的字节码指令,对于同步块的实现使用了monitorenter和monitorexit指令,他们隐式的执行了Lock和UnLock操作,用于提供原子性保证。 monitorenter指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个monitorenter都有一个monitorexit对应。

  • monitorenter:获取锁
  • monitorexit:释放锁
  • 再次monitorexit:异常释放锁

这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程是排他的,也就是说同一时刻只能有 一个线程获取到由synchronized所保护对象的监视器。

线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取对象的锁;而执行 monitorexit 就是释放monitor的所有权。

9.5 对象在内存中的布局

在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。其中对象头包含对象标记和类元信息两部分,Java对象头是实现 synchronized的锁对象的基础。一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。

9.6 Mark Word

Mark Word(对象标记)用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节, 也就是32bit)。
在这里插入图片描述

9.7 Monitor

什么是Monitor?

1.Monitor是一种用来实现同步的工具

2.与每个java对象相关联,所有的 Java 对象是天生携带 monitor

3.Monitor是实现Sychronized(内置锁)的基础

对象的监视器(monitor)由ObjectMonitor对象实现(C++),其跟同步相关的数据结构如下:

ObjectMonitor() {
    _count        = 0; //用来记录该对象被线程获取锁的次数
    _waiters      = 0;
    _recursions   = 0; //锁的重入次数
    _owner        = NULL; //指向持有ObjectMonitor对象的线程 
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}

9.8 synchronized的锁升级和获取过程(重点)

首先来了解相关锁的概念:

自旋锁(CAS):让不满足条件的线程等待一会看能不能获得锁,通过占用处理器的时间来避免线程切换带来的开销。自旋等待的时间或次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

偏向锁:大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的Mark Word里存储着指向当前线程的偏向锁,无需重新进行CAS操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁。

轻量级锁:减少无实际竞争情况下,使用重量级锁产生的性能消耗。JVM会现在当前线程的栈桢中创建用于存储锁记录的空间 LockRecord,将对象头中的 Mark Word 复制到 LockRecord 中并将 LockRecord 中的 Owner 指针指向锁对象。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,成功则当前线程获取到锁,失败则表示其他线程竞争锁当前线程则尝试使用自旋的方式获取锁。自旋获取锁失败则锁膨胀升级为重量级锁。

重量级锁:通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实 现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待被其他线程被唤醒,响应时间缓慢。
在这里插入图片描述

从网上找来的一张图,完美诠释了synchronized锁的升级过程。

9.9 Synchronized 结合 Java Object 对象中的 wait,notify,notifyAll

前面我们在讲 synchronized 的时候,发现被阻塞的线程什 么时候被唤醒,取决于获得锁的线程什么时候执行完同步代码块并且释放锁。那怎么做到显示控制呢?我们就需要借助一个信号机制:在 Object 对象中,提供了wait/notify/notifyall,可以用于控制线程的状态。

9.9.1 wait/notify/notifyall 基本概念

wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放 cpu 资源并进入等待状态。

notify:表示持有对象锁的线程 A 准备释放对象锁权限,通知 jvm 唤醒某个竞争该对象锁的线程 X。 线程 A synchronized 代码执行结束并且释放了锁之后,线程 X 直接获得对象锁权限,其他竞争线程继续等待(即使线程 X 同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的 notify ,notifyAll 被调用)。

notifyAll:notifyall 和 notify 的区别在于,notifyAll 会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程 A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限。

需要注意的是:三个方法都必须在 synchronized 同步关键字所限定的作用域中调用,否则会报错 java.lang.IllegalMonitorStateException 。意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法。另外,通过同步机制来确保线程从 wait 方法返回时能够感知到 notify 线程对变量做出的修改。

常见面试题:wait/notify/notifyall为什么需要在synchronized里面?

1.wait 方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是相关的,所以 wait 必须要获得一个监视器锁。

2.对于 notify 来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。

3.每个对象可能有多个线程调用wait方法,所以需要有一个等待队列存储这些阻塞线程。这个等待队列应该与这个对象绑定,在调用wait和notify方法时也会存在线程安全问题所以需要一个锁来保证线程安全。

9.9.2 wait/notify 的基本原理

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值