Java多线程相关

206 阅读52分钟

线程间通信的几种实现方式

 线程间通信的模型有两种:共享内存和消息传递,以下方式都是基于这两种模型来实现的。

使用volatile关键字

 基于volatile关键字来实现线程间相互通信是使用共享内存的思想,大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候,线程能够感知并执行相应的业务。这也是最简单的一种实现方式。

使用Object类的wait()和notify()方法

 众所周知,Object类提供了线程间通信的方法:wait()、notify()、notifyAll(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。

 wait和notify必须配合synchronized使用,wait方法释放锁,notify方法不释放锁。

1、wait让线程等待执行。

2、notify只能唤醒一个线程,notifyAll唤醒多个线程。

3、等待/通知模型除了用wait/notify,还可以用ReentrantLock的Condition的await/singnal实现。

使用JUC工具类的CountDownLatch

 jdk1.5之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了我们的并发编程代码的书写。CountDownLatch基于AQS框架,相当于也是维护了一个线程间共享变量state。

使用ReentrantLock结合Condition

public class TestSync {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        Condition condition = lock.newCondition();
        List list = new ArrayList<>();
        // 实现线程A
        Thread threadA = new Thread(() -> {
            lock.lock();
            for (int i = 1; i <= 10; i++) {
                list.add("abc");
                System.out.println("线程A向list中添加一个元素,此时list中的元素个数为:" + list.size());
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (list.size() == 5)
                condition.signal();
            }
            lock.unlock();
        });
        // 实现线程B
        Thread threadB = new Thread(() -> {
            lock.lock();
            if (list.size() != 5) {
                try {
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("线程B收到通知,开始执行自己的业务...");
            lock.unlock();
        });
        threadB.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }
}

 显然这种方式使用起来并不是很好,代码编写复杂,而且线程B在被A唤醒之后由于没有获取锁还是不能立即执行,也就是说,A在唤醒操作之后,并不释放锁。这种方法跟 Object 的 wait() 和 notify() 一样。

基本LockSupport实现线程间的阻塞和唤醒

 LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。

线程的5种状态

  1. 新建(new):新创建了一个线程对象。
  2. 可运行(Runnable):线程对象创建后,其它线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu的使用权。
  3. 运行(Running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。
  4. 阻塞(Blocked):阻塞状态是指线程因为某种原因放弃了cpu使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice转到运行(running)状态。阻塞的情况分三种:
    1. 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
    2. 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把线程放入线程池(lock pool)中。
    3. 其它阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。
  5. 死亡(Dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

线程的状态图:

image.png

线程池原理

 我们有两种常见的创建线程的方法,一种是继承Thread类,一种是实现Runnable的接口,Thread类其实也是实现了Runnable接口。但是我们创建这两种线程在运行结束后都会被虚拟机销毁,如果线程数量多的话,频繁的创建和销毁线程会大大浪费时间和效率,更重要的是浪费内存。那么有没有一种方法能让线程运行完后不立即销毁,而是让线程重复使用,继续执行其他的任务哪?

 这就是线程池的由来,很好的解决线程的重复利用,避免重复开销

线程池的优点

1、线程是稀缺资源,使用线程池可以减少创建和销毁线程的次数,每个工作线程都可以重复使用。

2、可以根据系统的承受能力,调整线程池中工作线程的数量,防止因为消耗过多内存导致服务器崩溃。

线程池的风险

 虽然线程池是构建多线程应用程序的强大机制,但使用它并不是没有风险的。用线程池构建的应用程序容易遭受任何其它多线程应用程序容易遭受的所有并发风险,诸如同步错误和死锁,它还容易遭受特定于线程池的少数其它风险,诸如与池有关的死锁、资源不足和线程泄漏。

  1. 死锁

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

  1. 资源不足

 线程池的一个优点在于:相对于其它替代调度机制(有些我们已经讨论过)而言,它们通常执行得很好。但只有恰当地调整了线程池大小时才是这样的。

 线程消耗包括内存和其它系统资源在内的大量资源。除了

 Thread 对象所需的内存之外,每个线程都需要两个可能很大的执行调用堆栈。除此以外,JVM 可能会为每个 Java

 线程创建一个本机线程,这些本机线程将消耗额外的系统资源。最后,虽然线程之间切换的调度开销很小,但如果有很多线程,环境切换也可能严重地影响程序的性能。

 如果线程池太大,那么被那些线程消耗的资源可能严重地影响系统性能。在线程之间进行切换将会浪费时间,而且使用超出比您实际需要的线程可能会引起资源匮乏问题,因为池线程正在消耗一些资源,而这些资源可能会被其它任务更有效地利用。

 除了线程自身所使用的资源以外,服务请求时所做的工作可能需要其它资源,例如 JDBC 连接、套接字或文件,这些也都是有限资源,有太多的并发请求也可能引起失效,例如不能分配 JDBC 连接。

  1. 并发错误

 线程池和其它排队机制依靠使用wait() 和 notify()方法,这两个方法都难于使用。如果编码不正确,那么可能丢失通知,导致线程保持空闲状态,尽管队列中有工作要处理。使用这些方法时,必须格外小心;即便是专家也可能在它们上面出错。而最好使用现有的、已经知道能工作的实现,例如在util.concurrent包。

  1. 线程泄漏

 各种类型的线程池中一个严重的风险是线程泄漏,当从池中除去一个线程以执行一项任务,而在任务完成后该线程却没有返回池时,会发生这种情况。发生线程泄漏的一种情形出现在任务抛出一个 RuntimeException 或一个 Error 时。

 如果池类没有捕捉到它们,那么线程只会退出而线程池的大小将会永久减少一个。当这种情况发生的次数足够多时,线程池最终就为空,而且系统将停止,因为没有可用的线程来处理任务。

  1. 请求过载

 仅仅是请求就压垮了服务器,这种情况是可能的。在这种情形下,我们可能不想将每个到来的请求都排队到我们的工作队列,因为排在队列中等待执行的任务可能会消耗太多的系统资源并引起资源缺乏。在这种情形下决定如何做取决于您自己;在某些情况下,您可以简单地抛弃请求,依靠更高级别的协议稍后重试请求,您也可以用一个指出服务器暂时很忙的响应来拒绝请求。

线程池的实现原理

ThreadPoolExecutor线程池类参数详解

参数说明
corePoolSize核心线程数量,线程池维护线程的最少数量
maximumPoolSize线程池维护线程的最大数量
keepAliveTime线程池除核心线程外的其他线程的最长空闲时间,超过该时间的空闲线程会被销毁
unitkeepAliveTime的单位,TimeUnit中的几个静态属性:NANOSECONDS、MICROSECONDS、MILLISECONDS、SECONDS
workQueue线程池所使用的任务缓冲队列
threadFactory线程工厂,用于创建线程,一般用默认的即可
handler线程池对拒绝任务的处理策略

线程池

image.png

线程池状态

 线程池和线程一样拥有自己的状态,在ThreadPoolExecutor类中定义了一个volatile变量runState来表示线程池的状态,线程池有四种状态,分别为RUNNING、SHURDOWN、STOP、TERMINATED。

  • 线程池创建后处于RUNNING状态。
  • 调用shutdown后处于SHUTDOWN状态,线程池不能接受新的任务,会等待缓冲队列的任务完成。
  • 调用shutdownNow后处于STOP状态,线程池不能接受新的任务,并尝试终止正在执行的任务。
  • 当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。

 线程池原理:预先启动一些线程,线程无限循环从任务队列中获取一个任务进行执行,直到线程池被关闭。如果某个线程因为执行某个任务发生异常而终止,那么重新创建一个新的线程而已,如此反复。

线程池的处理流程

  1. 判断线程池里的核心线程是否都在执行任务,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下个流程。
  2. 线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程(这个时候考虑一下是否已经到最大线程的数量)。
  3. 判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

image.png

配置线程池大小配置

 一般需要根据任务的类型来配置线程池大小:

  • 如果是CPU密集型任务,就需要尽量压榨CPU,参考值可以设为 NCPU+1

  • 如果是IO密集型任务,参考值可以设置为2*NCPU

 当然,这只是一个参考值,具体的设置还需要根据实际情况进行调整,比如可以先将线程池大小设置为参考值,再观察任务运行情况和系统负载、资源利用率来进行适当调整。

(线程池设计需要考虑的地方:一个是预估业务需要线程数量,设置好核心的线程数,二是预估线程处理的时间)

Java提供的四种线程池实现

(1)newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

(2)newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

(3)newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

(4)newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

四种线程池拒绝策略

  • ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
  • ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
  • ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
  • ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务

CAS原理

 CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。

 CAS需要有三个操作数:内存地址V、旧的预期值A和即将要更新的目标值B。

 CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。

CAS缺点

 CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。

1、循环时间长,开销大

 CAS通常是配合无限循环一起使用的,我们可以看到getAndAddInt方法执行时,如果CAS失败会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

2、只能保证一个变量的原子操作

 当对一个变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个变量操作时,CAS目前无法直接保证操作的原子性。但是我们可以通过以下两种方法来解决:

解决方法:

1) 使用互斥锁来保证原子性;

2) 将多个变量封装成对象,通过AtomicReference来保证原子性。

ABA问题

 CAS的使用流程通常如下:

1) 首先从地址V读取值A;

2) 根据A计算目标值B;

3) 通过CAS以原子的方式将地址V中的值从A修改为B。

 但是在第一步中读取的值是A,并且在第三步修改成功了,我们就能说它的值在第一步和第三步之间没有被其它线程改变过吗?

 如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,该用传统的互斥同步可能会比原子类更高效。

Lock的简介和使用

 Lock是Java1.5中引入的线程同步工具,它主要用于多线程下共享资源的控制。本质上Lock仅仅是一个接口(位于java.util.concurrent.locks中),它包含以下方法。

 Lock有三个实现类,一个是ReentrantLock(一般常用的是这个),另两个是ReentrantReadWriteLock类中的两个静态内部类ReadLock和WriteLock。

 使用方法:多线程下访问共享(互斥)资源时,访问前加锁,访问结束以后解锁,解锁的操作推荐放入finally块中。

image.png

 注意:加锁位于对资源访问的try块的外部,特别是使用lockInterruptibly方法加锁时就必须要这样做,这样是为了防止线程在获取锁时被中断,这时就不必(也不能)释放锁。

image.png

实现Lock接口的基本思想

 需要实现锁的功能,两个必备元素:

  • 一个是表示锁状态的变量(我们假设0表示没有线程获取锁,1表示已有线程占有锁),该变量必须声明为voaltile类型;

  • 另一个是队列,队列中的节点表示因未能获取锁而阻塞的线程。

 

 为了解决多核处理器下多线程缓存不一致的问题,表示状态的变量必须声明为voaltile类型,并且对表示状态的变量和队列的某些操作要保证原子性和可见性。原子性和可见性的操作主要通过Atomic包中的方法实现。

 线程获取锁的大致过程(这里没有考虑可重入和获取锁过程被中断或超时的情况):

1. 读取表示锁状态的变量

2. 如果表示状态的变量的值为0,那么当前线程尝试将变量值设置为1(通过CAS操作完成),当多个线程同时将表示状态的变量值由0设置成1时,仅一个线程能成功,其它线程都会失败:

 (1) 若成功,表示获取了锁

  ① 如果该线程(或者说节点)已位于在队列中,则将其出列(并将下一个节点变成了队列的头节点)

  ② 如果该线程未入列,则不用对队列进行维护,然后当前线程从lock方法中返回,对共享资源进行访问。

 (2) 若失败,则当前线程将自身放入等待(锁的)队列中并阻塞自身,此时线程一直被阻塞在lock方法中,没有从该方法中返回(被唤醒后仍然在lock方法中,并从下一条语句继续执行,这里又会回到第一步重新开始)。

3. 如果表示状态的变量的值为1,那么将当前线程放入等待队列中,然后将自身阻塞(被唤醒后仍然在lock方法中,并从下一条语句继续执行,这里又会回到第一步重新开始)。

注意:唤醒并不表示线程能立刻运行,而是表示线程处于就绪状态,仅仅是可以运行而已。

 

线程释放锁的大致过程:

1、释放锁的线程将状态变量的值从1设置为0,并唤醒等待(锁)队列中的队首节点,释放锁的线程就从unlock方法中返回,继续执行线程后面的代码。

2、被唤醒的线程(队列中的队首节点)和可能未进入队列且准备获取的线程竞争获取锁,重复获取锁的过程。

注意:可能有多个线程同时竞争去获取锁,但是一次只能有一个线程去释放锁,队列中的节点都需要它的前一个节点将其唤醒,例如有队列A<-B<-C,即由A释放锁时唤醒B,B释放锁时唤醒C。

公平锁和非公平锁

 锁可以分为公平锁和不公平锁,重入锁和非重入锁(关于重入锁的介绍会在ReentrantLock源代码分析中介绍),以上过程实际上是非公平锁的获取和释放过程。

 公平锁严格按照先来后到的顺序去获取锁,而非公平锁允许插队获取锁。

 公平锁获取锁的过程上有些不同,在使用公平锁时,某线程想要获取锁,不仅需要判断当前表示状态的变量的值是否为0,还要判断队列里是否还有其它线程,若队列中还有线程则说明当前线程需要排队,进行入列操作,并将自身阻塞;若队列为空,才能尝试去获取锁。而对于非公平锁,当表示状态的变量的值是为0,就可以尝试获取锁,不必理会队列是否为空,这样就实现了插队获取锁的特点。通常来说非公平锁的吞吐率比公平锁要高,我们一般常用非公平锁。

 这里需要解释一下,什么情况下才会出现,表示锁的状态的变量的值是为0,而且队列中仍有其它线程等待获取锁的情况。

 假设有三个线程A、B、C。A线程为正在运行的线程并持有锁,队列中有一个C线程,位于队首。现在A线程要释放锁,具体执行的过程操作可分为两步:

1、将表示锁状态的变量值由1变为0;

2、C线程被唤醒,这里要明确两点:

 (1) C线程被唤醒并不代表C线程开始执行,C线程此时是处于就绪状态,要等待操作系统的调度。

 (2) C线程目前还并未出列,C线程要进入运行状态,并且通过竞争获取到锁以后才会出列。

 如果C线程此时还没有进入运行态,同时未在队列中的B线程进行获取锁的操作,B就会发现虽然当前没有线程持有锁,但是队列不为空(C线程仍然位于队列中),要满足先来后到的特点(B在C之后执行获取锁的操作),B线程就不能去尝试获取锁,而是进行入列操作。

实现Condition接口的基本思想

 Condition本质是一个接口,它包含如下方法:

image.png

 一个Condition实例的内部实际上维护了一个队列,队列中的节点表示由于(某些条件不满足而)线程自身调用await方法阻塞的线程。Condition接口中有两个重要的方法,即await方法和signal方法。线程调用这个方法之前该线程必须已经获取了Condition实例所依附的锁。这样的原因有两个:

1、对于await方法,它内部会执行释放锁的操作,所以使用前必须获取锁。

2、对于signal方法,是为了避免多个线程同时调用同一个Condition实例的signal方法时引起的(队列)出列竞争。下面是这两个方法的执行流程。

 

await方法:

1、入列到条件队列(注意这里不是等待锁的队列)

2、释放锁

3、阻塞自身线程

-----------被唤醒后执行------------------

4、尝试去获取锁(执行到这里时线程已不在条件队列中,而是位于等待(锁的)队列中,参见signal方法)

(1) 成功,从await方法中返回,执行线程后面的代码

(2) 失败,阻塞自己(等待前一个节点释放锁时将它唤醒)

 注意:await方法是自身线程调用的,线程在await方法中阻塞,并没有从await方法中返回,当唤醒后继续执行await方法中后面的代码(也就是获取锁的代码)。可以看出await方法释放了锁,又尝试获得锁。当获取锁不成功的时候当前线程仍然会阻塞到await方法中,等待前一个节点释放锁后再将其唤醒。

 

signal方法:

1、将条件队列的队首节点取出,放入等待锁队列的队尾

2、唤醒该节点对应的线程

注意:signal是由其它线程调用

image.png

Lock与synchronized的区别

  1. Lock的加锁和解锁都是又java代码配合native方法(调用操作系统相关方法)实现的,而synchronize的加锁和解锁的过程是由JVM管理的
  2. 当一个线程使用synchronize获取锁时,若锁被其它线程占用着,那么当前只能被阻塞,直到成功获取锁。而Lock则提供超时锁和可中断等更加灵活的方式(即Condition的await方法和signal方法),在未能获取锁的条件下提供一种退出的机制。
  3. 一个锁内部可以有多个Condition实例,即有多路条件队列,而synchronize只有一路条件队列;同样Condition也提供灵活的阻塞方式,在未获得通知之前可以通过中断线程以及设置等待时限等方式退出条件队列。
  4. synchronize对线程的同步仅提供独占模式,而Lock既可以提供独占模式,也可以提供共享模式。

ReentrantLock的底层原理

ReentranLock整体结构如下图:

image.png

ReentrantLock实现Lock接口,基于内部的Sync实现。

Sync实现AQS,提供了FairSync和NonFairSync两种实现。

ReentrantLock底层使用了CAS+AQS队列实现,下面分别具体介绍两个技术。

CAS(Compare and Swap)

 CAS是一种无锁算法。有三个操作数:内存值V、旧的预期值A、要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

do{
    备份旧数据;
    基于旧数据构造新数据;
} while (!CAS ( 内存地址,备份旧的数据,新数据))

 该操作是一个原子操作,被广泛的应用在Java的底层实现中。

 在Java中,CAS主要是由sun.misc.Unsafe这个类通过JNI调用CPU底层指令实现。

CAS的开销

CAS速度非常快:

1、CAS是CPU指令级的操作,只有一步原子操作;

2、CAS避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。

 CAS仍然可能有消耗:可能出现cache miss的情况,会有更大的CPU时间消耗。

image.png

AQS队列

 AQS是一个用于构建和同步容器的框架。

 AQS使用一个FIFO的队列(也叫CLH队列,是CLH锁的一种变形),表示排队等待锁的线程。队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其它的节点与等待线程关联,每个节点维护一个等待状态waitStatus。结构如下图所示:

image.png

ReentrantLock的流程

1. ReentrantLock先通过CAS尝试获取锁

 (1) 如果此时锁已经被占用,该线程加入AQS队列并wait()

 (2) 当前线程的锁被释放,挂在CLH队列为首的线程就会被notify(),然后继续CAS尝试获取锁,此时:

  ① 非公平锁,如果有其它线程尝试lock(),有可能被其它刚好申请锁的线程抢占。

  ② 公平锁,只有在CLH队列头的线程才可以获取锁,新来的线程只能插入到队尾。

(注:ReentrantLock默认是非公平锁,也可以指定为公平锁)

 

ReentrantLock的两个构造函数


public ReentrantLock() {
    // 默认,非公平
    sync = new NonfairSync();

}

 

public ReentrantLock(boolean fair) {
    // 根据参数创建
    sync = fair ? new FairSync() : new NonfairSync();
}

lock()和unlock()的实现

lock()函数

 如果成功通过CAS修改了state,指定当前线程为该锁的独占线程,标志自己成功获取锁。

 如果CAS失败的话调用acquire();

// 非公平锁
final void lock() {
    if (compareAndSetState(0, 1))
    setExclusiveOwnerThread(Thread.currentThread());
    else
    acquire(1);
}

// 公平锁
final void lock() {
    acquire(1);
}

acquire()函数

 首先调用tryAcquire(),会尝试再次通过CAS修改state为1,如果失败而且发现锁是被当前线程占用的,就会执行重入(state+1);如果锁是被其它线程占有,那么当前线程执行tryAcquire()返回失败,并且执行addWaiter进入等待队列,并挂起自己interrupt()。

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

tryAcquire()

 检查state字段,若为0,表示锁未被占用,那么尝试占用;若不为0,检查当前锁是否被自己占用,若被自己占用,则更新state字段,表示重入锁的次数。如果以上两点都没有成功,则获取锁失败,返回false。

 // 注意:这是公平的tryAcquire()
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 需要先判断自己是不是队列的头
        if (!hasQueuedPredecessors() && compareAndSetState(0,acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
        throw new Error(“Maximum lock count exceeded”);
        setState(nextc);
        return true;
    }
    return false;
}


// 注意:这是非公平的tryAcquire()
final boolean tryAcquire(int acquires) {
    // 获取当前线程
    final Thread current = Thread.currentThread();
    // 获取state变量值
    int c = getState();
    // 没有线程占用锁
    if (c == 0) {
        // 没有!hasQueuedPredecessors()判断,不考虑自己是不是在队头,直接申请锁
        if (compareAndSetState(0,acquires)) {
            // 占用锁成功,设置独占线程为当前线程
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) { // 当前线程已占用该锁
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error(“Maximum lock count exceeded”);
            // 更新state值为新的重入次数
            setState(nextc);
            return true;
        }
        // 获取锁失败
        return false;
    }
}

addWaiter()

 当前线程加入AQS双向链表队列。写入之前需要将当前线程包装为一个Node对象(addWaiter(Node.EXCLUSIVE))。首先判断队列是否为空,不为空时则将封装好的Node利用CAS写入队尾,如果出现并发写入失败就需要调用enq(node);来写入了。

/**
* 将新节点和当前线程关联并且入队列**  
*@param mode 独占/共享
*@return 新节点
*/
private Node addWaiter(Node mode) {  
    // 初始化节点,设置关联线程和模式(独占or共享)  
    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;  
        }  
    }  
    // 尾节点为空,说明队列还未初始化,需要初始化head节点并入队新节点  
    enq(node);  
    return node;  
}  
  
private  Node enq(final Node node) {  
    // 自旋,直到初始化队列成功  
    for (; ; ) {  
        Node t = tail;  
        if (t == null) { // Must initialize  
            if (compareAndSetHead(new Node()))  
                tail = head;  
        } else {  
            node.prev = t;  
            if (compareAndSetTail(t, node)) {  
                t.next = node;  
                return t;  
            }  
        }  
    }  
}

 enq()的处理逻辑就相当于自旋加上CAS保证一定能写入队列。

acquireQueued()

写入队列后,需要挂起当前线程

final boolean acquireQueued(final Node node, int arg) {  
    boolean failed = true;  
    try {  
        boolean interrupted = false;  
        for (;;) {  
            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.predecessor()获取到上一个节点是否为头节点,如果是则尝试获取一次锁,获取成功就万事大吉了。

 如果不是头节点,或者获取锁失败,则会根据上一个节点的waitStatus状态来处理(shouldParkAfterFailedAcquire(p, node))。

waitStatus用于记录当前节点的状态,如节点取消、节点等待等等。

shouldParkAfterFailedAcquire(p, node)返回当前线程是否需要挂起,如果需要则调用parkAndCheckInterrupt():

private final boolean parkAndCheckInterrupt() {  
    LockSupport.*park*(this);  
    return Thread.*interrupted*();  
}

 它是利用LockSupport的part方法来挂起当前线程的,直到被唤醒。

unlock()

 释放的时候,state--,通过state==0判断锁是否完全被释放。

 成功释放锁的话,唤起一个被挂起的线程。

public void unlock() {  
    sync.release(1);  
}  
  
public final boolean release(int arg) {  
    if (tryRelease(arg)) {  
        Node h = head;  
        if (h != null && h.waitStatus != 0)  
            // 唤醒等待的线程  
            unparkSuccessor(h);  
        return true;  
    }  
    return false;  
}  
  
protected final boolean tryRelease(int release) {  
    int c = getState() - release;  
    if (Thread.*currentThread*() != getExclusiveOwnerThread())  
        throw new IllegalMonitorStateException();  
    boolean free = false;  
    if (c == 0) {  
        free = true;  
        setExclusiveOwnerThread(null);  
    }  
    setState(c);  
    return free;  
}

总结

1、每一个ReentrantLock自身维护一个AQS队列记录申请锁的线程信息;

2、通过大量CAS保证多个线程竞争锁的时候的并发安全;

3、可重入的功能是通过维护state变量来记录重入次数实现的;

4、公平锁需要维护队列,通过AQS队列的先后顺序获取锁,缺点是会造成大量线程上下文切换;

5、非公平锁可以直接抢占,所以效率更高;

Sync实现类

FairSync

NonfairSync是ReentrantLock的内部静态类,实现Sync抽象类,非公平锁实现类。

image.png

NonfairSync

FairSync是ReentrantLock的内部静态类,实现Sync抽象类,公平锁实现类。

image.png

ReentrantLock和synchronized的区别

image.png

volatile如何解决指令重排序

可见性

 对volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会;立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改。

 如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据。

 lock前缀指令+MESI缓存一致性协议实现了可见性。

volatile禁止指令重排序

 volatile实现禁止指令重排序优化,从而避免了多线程环境下程序出现乱序执行的现象。

 首先了解一个概念,内存屏障(Memory Barrier)又称为内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的顺序

  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

 由于编译器和处理器都能执行指令重排序的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。

image.png

 也就是在volatile的读和写的时候,加入内存屏障,防止出现指令重排。

线程安全获得保证

 工作内存与主内存同步延迟现象导致的可见性问题

  • 可通过synchronized或volatile关键字解决,它们都可以使一个线程修改后的变量立即对其它线程可见。

  • 可以使用volatile关键字解决,因为volatile关键字的另一个作用就是禁止指令重排序优化

Atomic类

 atomic类是通过自旋CAS操作volatile变量实现的。

Atomic类的缺点:

  • ABA问题:对于一个旧的变量值A,线程2将A的值改成B又改成A,此时线程1通过CAS看到A并没有变化,但实际A已经发生了变化,这就是ABA问题。解决这个问题的方法很简单,记录一下变量的版本就可以了,在变量的值发生变化时对应的版本也作出相应的变化,然后CAS操作时比较一下版本就知道变量有没有发生变化。atomic包下AtomicStampedReference类实现了这种思路。Musql中Innodb的多版本并发锁也是这个原理。

  • 自旋问题:atomic类会多次尝试CAS操作直至成功或失败,这个过程叫自旋。通过自旋的过程我们可以看出自旋操作不会将线程挂起,从而避免了内核线程切换,但是自旋的过程也可以看作CPU死循环,会一直占用CPU资源。这种情形在单CPU的极其上是不能容忍的,因此自旋一般都会有次数限制,即超过这个次数之后线程就会放弃时间片,等待下次机会。因此自旋操作在资源竞争不激烈的情况下确实能提高效率,但是在资源竞争特别激烈的场景中,CAS操作的失败率就会大大提高,这时使用重量级锁的效率可能会更高。当前,也可以使用LongAdder类来替换,它则采用了分段锁的思想来解决并发竞争问题。

为什么volatile不能保证原子性而Atomic可以

 在Java中long赋值不是原子操作,因为先写32位,再写后32位,分两步操作,而AtomicLong赋值是原子操作,为什么?为什么volatile能代替简单的锁,却不能保证原子性?这里面涉及volatile,是Java中的一个我觉得从未被解释清除的神奇关键词,在Sun的JDK官方文档中是这样形容volatile的:

 The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes. A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.

 意思就是说,如果一个变量加了volatile关键字,就会告诉编译器和JVM的内存模型:这个变量是对所有线程共享的、可见的,每次jvm都会读取最新写入的值并使其最新值在所有CPU可见。volatile似乎是有时候可以代替简单的锁,似乎加了volatile关键字就省掉了锁。但又说volatile不能保证原子性(Java程序员很熟悉这句话:volatile仅仅用来保证该变量对所有线程的可见性,但不能保证原子性)。这不是互相矛盾吗?

 不要将volatile用在getAndOperate场合(这种场合不原子,需要再加锁),仅仅set或者get的场景是适合volatile的。

 volatile没有原子性。举例:

 例如让一个volatile的integer自增(i++),其实要分成三步:1)读取volatile变量值到local;2)增加变量的值;3)把local的值写回,让其它线程可见。这三步的jvm指令为:

image.png

 注意最后一步是内存屏障。

 什么是内存屏障(Memory Barrier)?

 内存屏障是一个CPU指令。基本上,它是这样一条指令:a)确保一些特定操作执行的顺序;b)影响一些数据的可见性(可能是某些指令执行后的结果)。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个指令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个CPU核心或者哪颗CPU执行的。

 内存屏障和volatile是什么关系?上面的虚拟机指令里面有提到,如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

 volatile为什么没有原子性?明白了内存屏障这个CPU指令,回到前面的JVM指令:从Load到store到内存屏障,一共四步,其中最后一步JVM让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store)是不安全的,中间如果其它的CPU修改了值将会丢失。下面的测试代码可以实际测试volatile的自增没有原子性:

image.png

上面是一段线程不安全的singleton(单例模式)实现,尽管使用了volatile,原因自然是volatile保证变量对线程的可见性,但不保证原子性。正确线程安全的单例模式写法:

image.png

延迟初始化写法:

image.png

二次检查锁定(双检锁,Double Checked Locking)写法:

image.png

为什么AtomicXXX具有原子性和可见性?就拿AtomicLong来说,它即解决了上述的volatile的原子性没有保证的问题,又具有可见性。它是如何做到的?

其实AtomicLong的源码里也用到了volatile,但只是用来读取或写入,可见源码:

image.png

其CAS源码核心代码为:

image.png

虚拟机指令为:

image.png

 因为CAS是基于乐观锁的,也就是说当写入的时候,如果寄存器旧值已经不等于现值,说明有其它CPU在修改,那就继续尝试,这就保证了操作的原子性。

image.png

共享锁

 共享锁:该锁可被多个线程所持有。典型的就是RentranrReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁每次只能被独占。

 对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

 读锁的共享锁可保证并发读是非常高效的,读写、写读、写写的过程是互斥的。

 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

synchronized底层实现原理及锁升级

概述

synchronized作用:

原子性:synchronized保证语句块内操作是原子的。

可见性:synchronized保证可见性(通过在执行unlock之前,必须先吧此变量同步回主内存实现)

有序性:synchronized保证有序性(通过一个变量在同一时刻只允许一条线程对其进行lock操作)

 

synchronized的使用:

1、修饰实例方法,对当前实例对象加锁

2、修饰静态方法,对当前类的Class对象加锁

3、修饰代码块,对synchronized括号内的对象加锁

实现原理

 jvm基于进入和退出Monitor对象来实现方法同步和代码块同步。方法级的同步是隐式的,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure)中的ACC_SYNCHRONIZED访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有monitor(此时把方法看成一个对象)(虚拟机规范中用的管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

 代码块的同步时利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器加1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程就会进入阻塞态,直到其它线程释放锁。

这里要注意:

1、synchronized是可重入的,所以不会自己把自己锁死

2、synchronized锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞。

 

 关于ACC_SYNCHRONIZED、monitorenter、monitorexit指令,可以看一下下面的反编译代码:

public class SynchronizedDemo {
    public synchronized void f(){ // 这个是同步方法
        System.out.println(“Hello world”);
    }
    
    public void g(){
        synchronized(this) { // 这个是同步代码块
            System.out.println(“Hello World”);
        }
    }

    public static void main(String[] args) {

    }
}

 使用javap -verbose SynchronizedDemo反编译后得到

image.png

image.png

 我们看到对于同步方法,反编译后得到ACC_SYNCHRONIZED 标志,对于同步代码块反编译后得到monitorenter和monitorexit指令。

理解Java对象头

 在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

image.png

实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

 HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32位和64位。官方称为Mark Word。另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。

虚拟机位数对象头结构描述
32位/64位Mark Word存储对象的哈希码、GC分代年龄、锁信息等
32位/64位Class MetaData Address指向对象类型数据的指针
32位/64位数组长度如果是数组对象的话,有这一部分,否则没有

 由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。

image.png

JVM对synchronized的锁优化(锁升级)

 Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。

 Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。

偏向锁

 偏向锁是JDK1.6中引用的优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。

偏向锁的获取:

  1. 判断是否为可偏向状态
  2. 如果为可偏向状态,则判断线程ID是否是当前线程,如果是进入同步块;
  3. 如果线程ID并未指向当前线程,利用CAS操作竞争锁,如果竞争成功,将Mark Word中线程ID更新为当前线程ID,进入同步块
  4. 如果竞争失败,等待全局安全点,准备撤销偏向锁,根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。

 当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

偏向锁的释放:

 偏向锁使用了遇到竞争才释放锁的机制。偏向锁的撤销需要等待全局安全点,然后它会首先暂停拥有偏向锁的线程,然后判断线程是否还活着,如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。

image.png

轻量级锁

 轻量级锁也是在JDK1.6中引入的新型锁机制。它不是用来替换重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

加锁过程:

 在代码进入同步块的时候,如果此对象没有被锁定(锁标志位为“01”状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。然后虚拟机使用CAS操作尝试将对象的Mark Word更新为指向锁记录(Lock Record)的指针。如果更新成功,那么这个线程就拥有了该对象的锁,并且对象的Mark Word标志位转变为“00”,即表示此对象处于轻量级锁定状态;如果更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块中执行,否则说明这个锁对象已经被其他线程占有了。如果有两条以上的线程竞争同一个锁,那轻量级锁不再有效,要膨胀为重量级锁,锁标志变为“10”,Mark Word中存储的就是指向重量级锁的指针,而后面等待的线程也要进入阻塞状态。

image.png

image.png

解锁过程:

 如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作将对象当前的Mark Word与线程栈帧中的Displaced Mark Word交换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

 如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统重量级锁开销更大。

重量级锁

 Synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。

自旋锁

 互斥同步对性能影响最大的是阻塞的实现,挂起线程和恢复线程的操作都需要转入到内核态中完成,这些操作给系统的并发性能带来很大的压力。

于是在阻塞之前,我们让线程执行一个忙循环(自旋),看看持有锁的线程是否释放锁,如果很快释放锁,则没有必要进行阻塞。

锁消除

 锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是检测到不可能发生数据竞争的锁进行消除。

锁粗化

 如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

锁升级(总结)

 无锁 - 偏向锁 -轻量级锁(自旋锁)-重量级锁

 偏向锁 - markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。hashCode备份在线程栈上 线程销毁,锁降级为无锁

 有争用 - 锁升级为轻量级锁 - 每个线程有自己的LockRecord在自己的线程栈上,用CAS去争用markword的LR的指针,指针指向哪个线程的LR,哪个线程就拥有锁

自旋超过10次,升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU)-XX:PreBlockSpin

 自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

 自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

 偏向锁由于有锁撤销的过程revoke,会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。

ThreadLocal

ThreadLocal简介

 多线程访问同一个共享变量的时候容易出现并发问题,特别是多个线程对一个变量进行写入的时候,为了保证线程安全,一般使用者在访问共享变量的时候需要进行额外的同步措施才能保证线程安全性。ThreadLocal是除了加锁这种同步方式之外的一种保证一种规避多线程访问出现线程不安全的方法,当我们在创建一个变量后,如果每个线程对其进行访问的时候访问的都是线程自己的变量这样就不会存在线程不安全问题。

 threadlocal而是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据,官方解释如下

 ThreadLocal是JDK包提供的,它提供线程本地变量,如果创建一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个副本,在实际多线程操作的时候,操作的是自己本地内存中的变量,从而规避了线程安全问题,如下图所示

image.png

ThreadLocal简单使用

image.png

ThreadLocal实现原理

ThreadLocal的原理:每个Thread内部维护着一个ThreadLocalMap,它是一个Map。这个映射表的Key是一个弱引用,其实就是ThreadLocal本身,Value是真正存的线程变量Object(强引用)。

也就是说ThreadLocal本身并不真正存储线程的变量值,它只是一个工具,用来维护Thread内部的Map,帮助存和取。注意上图的虚线,它代表一个弱引用类型,而弱引用的生命周期只能存活到下次GC前。

ThreadLocal造成内存泄漏

ThreadLocal为什么会造成内存泄漏

 ThreadLocal在ThreadLocalMap中是以一个弱引用身份被Entry中的Key引用的,因此如果ThreadLocal没有外部强引用来引用它,那么ThreadLocal会在下次JVM垃圾收集时被回收。这个时候就会出现Entry中Key已经被回收,出现一个null Key的情况,外部读取ThreadLocalMap中的元素是无法通过null Key来找到Value的。因此如果当前线程的生命周期很长,一直存在,那么其内部的ThreadLocalMap对象也一直生存下来,这些null key就存在一条强引用链的关系一直存在:Thread --> ThreadLocalMap-->Entry-->Value,这条强引用链会导致Entry不会回收,Value也不会回收,但Entry中的Key却已经被回收的情况,造成内存泄漏。

 但是JVM团队已经考虑到这样的情况,并做了一些措施来保证ThreadLocal尽量不会内存泄漏:在ThreadLocal的get()、set()、remove()方法调用的时候会清除掉线程ThreadLocalMap中所有Entry中Key为null的Value,并将整个Entry设置为null,利于下次内存回收。

java中的四种引用类型

  • 强引用:Java中默认的引用类型,一个对象如果具有强引用那么只要这种引用还存在就不会被GC。

  • 软引用:简言之,如果一个对象具有弱引用,在JVM发生OOM之前(即内存充足够使用),是不会GC这个对象的;只有到JVM内存不足的时候才会GC掉这个对象。软引用和一个引用队列联合使用,如果软引用所引用的对象被回收之后,该引用就会加入到与之关联的引用队列中

  • 弱引用(这里讨论ThreadLocalMap中的Entry类的重点):如果一个对象只具有弱引用,那么这个对象就会被垃圾回收器GC掉(被弱引用所引用的对象只能生存到下一次GC之前,当发生GC时候,无论当前内存是否足够,弱引用所引用的对象都会被回收掉)。弱引用也是和一个引用队列联合使用,如果弱引用的对象被垃圾回收期回收掉,JVM会将这个引用加入到与之关联的引用队列中。若引用的对象可以通过弱引用的get方法得到,当引用的对象呗回收掉之后,再调用get方法就会返回null

  • 虚引用:虚引用是所有引用中最弱的一种引用,其存在就是为了将关联虚引用的对象在被GC掉之后收到一个通知。(不能通过get方法获得其指向的对象)

为什么使用弱引用?

 从表面上看,发生内存泄漏,是因为Key使用了弱引用类型。但其实是因为整个Entry的key为null后,没有主动清除value导致。很多文章大多分析ThreadLocal使用了弱引用会导致内存泄漏,但为什么使用弱引用而不是强引用?

官方文档的说法:

 To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.

 为了处理非常大和生命周期非常长的线程,哈希表使用弱引用作为 key。

 下面我们分两种情况讨论:

  • key 使用强引用:引用的ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
  • key 使用弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

 比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

 因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key的value就会导致内存泄漏,而不是因为弱引用。

总结

 综合上面的分析,我们可以理解ThreadLocal内存泄漏的前因后果,那么怎么避免内存泄漏呢?

  • 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。