Java并发编程

Java并发编程


本文主要参考JavaGuide和黑马的资料,然后整理凝练了一些Java并发编程的八股。

JMM(Java 内存模型)

  • Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • 多线程下,指令重排序可能会导致一些问题。
  • JMM 为共享变量提供了可见性的保障。JMM 说白了就是定义了一些规范来解决指令重排序带来的问题,开发者可以利用这些规范更方便地开发多线程程序。
  • Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):锁定(lock)。解锁(unlock)。read(读取)。load(载入)。use(使用)。assign(赋值)。store(存储)。write(写入)。

happens-before 原则

happens-before 原则的设计思想:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
    happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

happens-before 和 JMM 什么关系?

图源自《Java 并发编程的艺术》
在这里插入图片描述

什么是线程和进程?

  • 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态
  • 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。各个线程之间作切换工作时,负担要比进程小得多
  • 一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
  • 基本上各进程是独立的,而各线程则不一定;线程执行开销小,但不利于资源的管理和保护
    补:程序计数器私有主要是为了线程切换后能恢复到正确的执行位置虚拟机栈和本地方法栈是线程私有的,保证线程中的局部变量不被别的线程访问到。

并发与并行的区别

并发:两个及两个以上的作业在同一 时间段 内执行。
并行:两个及两个以上的作业在同一 时刻 执行。

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,问题,比如:内存泄漏、死锁、线程不安全

线程的生命周期和状态

NEW: 初始状态。RUNNABLE: 运行状态。BLOCKED :阻塞状态。WAITING:等待状态。TIME_WAITING:超时等待状态。TERMINATED:终止状态。

在这里插入图片描述
图源:《Java 并发编程的艺术》中关于线程状态

什么是上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。(主动让出 CPU,比如调用了 sleep(), wait() 。时间片用完。调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞)

sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法

  • wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,自然是要操作对应的对象(Object)而非当前的线程(Thread)
  • sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁

volatile 关键字

  • volatile 关键字可以保证变量的可见性,变量是共享且不稳定的,每次使用它都到主存中进行读取。
  • volatile 还可以防止 JVM 的指令重排序
  • volatile 不能保证对变量的操作是原子性
  • 双重校验锁实现对象单例(线程安全)
public class Singleton {
    // 加 volatile
    private volatile static Singleton uniqueInstance;

    private Singleton() {}

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

乐观锁和悲观锁

悲观锁

  • 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
  • Java 中synchronizedReentrantLock独占锁就是悲观锁思想的实现
  • 通常多用于写比较多的情况下(多写场景)

乐观锁

  • 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(版本号机制CAS 算法)。
  • 在 Java 中java.util.concurrent.atomic包下面的原子变量类Atomic就是使用了乐观锁的一种实现方式 CAS 实现的。
  • 通常多于写比较少的情况下(多读场景)

如何实现乐观锁?

乐观锁一般会使用版本号机制CAS 算法实现

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值版本相等时才更新,否则重试更新操作,直到更新成功。

CAS

  • CAS 的全称是 Compare And Swap(比较与交换),被广泛应用于各大框架中。就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
  • CAS 涉及到三个操作数:
    V :要更新的变量值(Var)
    E :预期值(Expected)
    N :拟写入的新值(New)
    当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

乐观锁存在哪些问题?

ABA 问题;循环时间长开销大;只能保证一个共享变量的原子操作

  • ABA 问题的解决思路是在变量前面追加上版本号或者时间戳
  • CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销
  • CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。

synchronized 关键字

主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

  • 在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。
  • 在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。
  • synchronized 可以修饰:实例方法 (锁当前对象实例);静态方法 (锁当前);代码块 (锁指定对象/类)
    • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能
  • 构造方法不能使用 synchronized 关键字修饰。构造方法本身就属于线程安全的。

synchronized 底层原理

  • synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
  • synchronized 修饰的方法使用的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。(实例方法,尝试获取实例对象的锁。静态方法,尝试获取当前 class 的锁)
  • 两者的本质都是对对象监视器 monitor 的获取

synchronized 和 volatile 有什么区别?

互补而不是对立

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量。保证数据的可见性,但能保证数据的原子性。用于解决变量在多个线程之间的可见性
  • synchronized 关键字可以修饰方法以及代码块可见性、原子性都能保证。 解决的是多个线程之间访问资源的同步性

ReentrantLock

  • 可重入独占式的锁。ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
  • ReentrantLock 的底层就是由 AQS 来实现的。
  • ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁

synchronized 和 ReentrantLock 有什么区别?

  1. 两者都是可重入锁。也叫递归锁,指的是线程可以再次获取自己的内部锁。
  2. synchronized 依赖于 JVM 虚拟机层面; ReentrantLock 依赖于 API
  3. ReentrantLock 比 synchronized 增加了一些高级功能:等待可中断;可实现公平锁;可实现选择性通知(锁可以绑定多个条件)

可中断锁 :获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
不可中断锁 :一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

ReentrantReadWriteLock

  • ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
  • 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
  • ReentrantReadWriteLock 底层也是基于 AQS 实现的。
  • ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。
  • 适合场景:读多写少

共享锁 :一把锁可以被多个线程同时获得。
独占锁 :一把锁只能被一个线程获得。

读锁为什么不能升级为写锁?

  1. 写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。
  2. 还可能会有死锁问题发生。

java中锁有哪些类型?

在这里插入图片描述
美团技术团队:不可不说的Java“锁”事

如何优雅的停止一个线程

中断 interrupt() 方法

  1. interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行。
  2. 使用中断机制:Java提供了中断机制来停止线程。当调用一个线程的interrupt()方法时,它会设置该线程的中断状态。线程可以通过检查自己的中断状态来决定是否停止执行。这种方法需要注意避免在代码中忽略中断异常。料理后事。
  3. interrupt 是中断信号传递,基于系统层次的,不受阻塞影响。

volatile 标记位的停止方法

  1. 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性。但是当出现阻塞等时无法进行及时的通知。
  2. 就是一个设置标记,让线程可见,进而终止程序。

线程池

管理一系列线程的资源池。有任务要处理时,直接从线程池中获取来处理,处理完之后并不会立即被销毁,而是等待下一个任务。

使用线程池的好处?

池化技术(线程池、数据库连接池、Http 连接池)等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率

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

如何创建线程?

方式一:继承于Thread类

步骤:
1.创建一个继承于Thread类的子类
2.重写Thread类的 run() --> 将此线程执行的操作声明在run()中
3.创建Thread类的子类的对象
4.通过此对象调用 start() 执行线程

方式二:实现Runnable接口

步骤:
1.创建一个实现了Runnable接口的类
2.实现类去实现Runnable中的抽象方法:run()
3.创建实现类的对象
4.将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
5.通过Thread类的对象调用 start()
① 启动线程
②调用当前线程的run()–>调用了Runnable类型的target的run()

方式三:使用线程池,如下

如何创建线程池?ThreadPoolExecutor

方式一:通过ThreadPoolExecutor构造函数来创建(推荐)。

重要参数

public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                              int maximumPoolSize,//线程池的最大线程数
                              long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                              TimeUnit unit,//时间单位
                              BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
                              ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                              RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                               ) {。。。。。。}

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize :任务队列未达到队列容量时,最大可以同时运行的线程数量,核心线程数
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在任务队列中。
    ThreadPoolExecutor其他常见参数 :
  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略

线程池的饱和策略

  • ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

线程池常用的阻塞队列

  • LinkedBlockingQueue(无界队列): 容量为 Integer.MAX_VALUE ,FixedThreadPool 和 SingleThreadExector 。由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。
  • SynchronousQueue(同步队列) :CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

线程池处理任务的流程

在这里插入图片描述

设定线程池的大小

  • 太小的话,大量的请求/任务在任务队列中排队等待执行,甚至可能队列满了无法处理,可能OOM。
  • 太大的话,大量线程会同时在争取 CPU 资源,导致大量的上下文切换,增加线程的执行时间,影响了整体执行效率。
  • CPU 密集型任务(N+1):利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。
  • I/O 密集型任务(2N):网络读取,文件读取这类,大部分时间都花在了等待 IO 操作完成上。

AQS

  • AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。AQS 就是一个抽象类,为构建同步器提供了一些通用功能的是实现。
  • AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

  • AQS 使用 int 成员变量 state 表示同步状态,由 volatile 修饰,用于展示当前临界资源的获锁情况

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 and Swap) 减 1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作

ReentrantLock

前面写过

Semaphore

  • Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。
  • 假设有 N(N>5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞
  • 当初始的资源个数为 1 的时候,Semaphore 退化为排他锁
  • Semaphore 有两种模式:公平模式,遵循 FIFO;非公平模式,抢占式。
  • Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。

Semaphore原理

Semaphore 是共享锁的一种实现,默认构造 AQSstate 值为 permits,可以理解为许可证的数量。

  • semaphore.acquire() ,线程尝试获取许可证, 如果state >= 0 的话,表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,会创建一个 Node 节点加入阻塞队列,挂起当前线程。
  • semaphore.release(),线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功否则重新进入阻塞队列,挂起线程。

CountDownLatch

  • 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕
  • CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,它不能再次被使用。
  • 应用:使用多线程读取多个文件处理的场景

CountDownLatch 的原理

CountDownLatch 是共享锁的一种实现,它默认构造 AQSstate 值为 count。当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。然后,CountDownLatch 会自旋 CAS 判断 state = 0,如果 state = 0 的话,就会释放所有等待的线程,await() 方法之后的语句得到执行。

CyclicBarrier

  • CyclicBarrier 意思是可循环使用(Cyclic)的屏障(Barrier)。让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续执行。
  • CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大
  • CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock(ReentrantLock 也属于 AQS 同步器)和 Condition 的。

CyclicBarrier 的原理

CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。

ThreadLocal

ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。提供线程内局部变量,为每一个线程都提供了一份变量的副本。不同线程之间不会相互干扰,线程隔离。减少同一个线程内的函数 或 组件之间传递变量的复杂性。

特点

  1. 线程并发:在多线程并发场景下。
  2. 传递数据:我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量
    (保存每个线程的数据,在需要的地方可以直接获取, 避免参数直接传递带来的代码耦合问题)
  3. 线程隔离:每个线程的变量都是独立的, 不会互相影响(核心)。
    (各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失)

ThreadLocal的内部结构

JDK8中ThreadLocal的设计是 : 每个Thread维护一个ThreadLocalMap, 这个Map的keyThreadLocal实例本身,value才是真正要存储的值Object

  • 具体过程:
  1. 每个ThreadLocal线程内部都有一个Map(ThreadLocalMap)。
  2. Map里面存储的ThreadLocal对象(key)和线程变量副本(Value)也就是存储的值。
  3. Thread内部的Map是由ThreadLocal维护的, 由ThreadLocal负责向map获取和设置线程变量值。
  4. 对于不同的线程, 每次获取副本值时别的线程并不能获取当前线程的副本值, 形成了副本的隔离,互不干扰。
  • 设计的好处:
  1. 每个Map存储的Entry数量变少
  2. Thread销毁的时候, THreadLocalMap也会随之销毁, 减少内存的使用.
    在这里插入图片描述

ThreadLocal 核心源码分析

set()

  1. 首先获取当前线程,并根据当前线程获取一个Map
  2. 如果获取的Map不为空,则将参数设置到Map中(当前 ThreadLocal 作为 key)。
  3. 如果Map为空, 则给该线程创建 Map, 并设置初始值。

get()

  1. 获取当前线程对象 和 ThreadLocalMap。
  2. 如果 map 存在, 则直接返回 map 中 ThreadLocal 对应的值。
  3. map 不存在 或 map 中没 set 过,设置初始化,并返回。

remove()

  1. 获取当前线程中保存 ThreadLocalMap。
  2. 若存在则调用 map.remove 方法。

ThreadLocalMap 核心源码分析

ThreadLocal 的操作实际上时围绕 ThreadLocalMap 展开的。

  • 初始容量为16,必须是 2 的整数次幂;数组的长度必须是 2 的整数次幂。
  • 在 ThreadLocalMap 中, 也是使用 Entry 来保存 K-V 结构数据的。不过 Entry 中的Key只能是 ThreadLocal 对象
  • Entry 继承自 WeakReference,也就是 key (ThreadLocal) 是弱引用,其目的是将 ThreadLocal 对象的生命周期*和 线程的生命周期解绑

ThreadLocalMap中的key使用了弱引用, 那么会出现内存泄漏吗?

在这里插入图片描述

  1. 假设在业务代码中使用完ThreadLocal, ThreadLocal Ref被回收了。
  2. 由于ThreadLocalMap只持有ThreadLocal的弱引用, 没有任何强引用指向threadlocal实例, 所以threadlocal就可以顺利被gc回收, 此时Entry中的key = null。
  3. 没有手动删除Entry以及CurrentThread依然运行的前提下, 也存在有强引用链 threadRef → currentThread → value ,value就不会被回收, 而这块value永远不会被访问到了, 导致value内存泄漏
    也就是说: ThreadLocalMap中的key使用了弱引用, 也有可能内存泄漏

内存泄漏的真实原因

在这里插入图片描述
比较以上两种情况,我们就会发现:内存泄漏的发生跟 ThreadLocalIMap 中的 key 是否使用弱引用是没有关系的。那么内存泄漏的的真正原因是什么呢?

细心的同学会发现,在以上两种内存泄漏的情况中.都有两个前提
1 .没有手动侧除这个 Entry
2 · CurrentThread 依然运行

第一点很好理解,只要在使用完下 ThreadLocal ,调用其 remove 方法删除对应的 Entry ,就能避免内存泄漏。
第二点稍微复杂一点,由于ThreadLocalMap 是 Thread 的一个属性,被当前线程所引用,所以它的生命周期跟 Thread 一样长。那么在使用完 ThreadLocal 的使用,如果当前Thread 也随之执行结束, ThreadLocalMap 自然也会被 gc 回收,从根源上避免了内存泄漏。

综上, ThreadLocal 内存泄漏的根源是:由于ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏

为什么弱引用会导致内存泄漏,还是要使用弱引用呢?

事实上,在 ThreadLocalMap 中的set/getEntry 方法中,会对 key 为 null (也即是 ThreadLocal 为 null )进行判断,如果为key为 null 的话,那么 value 也会置为 null 的。
​ 这就意味着使用完 ThreadLocal , CurrentThread 依然运行的前提下.就算忘记调用 remove 方法弱引用比强引用可以多一层保障弱引用的 key 也就是 ThreadLocal 会被回收,对应的value在下一次 ThreadLocaIMap 调用 set/get/remove 中的任一方法的时候会被清除,从而避免内存泄漏

Hash冲突的解决

ThreadLocalMap使用线性探测法来解决哈希冲突的。

  • 代码执行流程:
  1. 首先还是根据key计算出索引 i,然后查找i位置上的Entry
  2. 若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry 赋新的value值
  3. 若是Entry存在,但是key为null,则调用 replaceStaleEntry更换这个key为空的Entry。
  4. 不断循环检测,可以看成是一个环形数组,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。
  5. 最后调用 cleanSomeSlots清理key为null的Entry,最后返回是否清理了Entry,接下来再判断 size 是否>= thresgold达到了rehash的条件,达到的话就会调用rehash函数执行一次全表的扫描清理
  • 计算索引位置:采用了 hashcode & (size - 1) 的方式,相当于取余操作 hashCode % size 的一个更高效的实现,正是因为这种算法,我们要求 size 必须是 2 的整数次幂,这也能保证在索引不越界的情况下,使得 hash 发生冲突的次数减小。
  • 定义了一个 AutomicInteger 类型,每次当前值并加上 HASH_INCREMENT。AutomicInteger 是一个提供原子操作的 Integer 类, 通过线程安全的方式操作加减,适合高并发的情况下使用。
  • 特殊的 hash 值:这个 HASH_INCREMENT = 0x61c88647是和斐波拉契数列(黄金分割数)有关,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里,也就是 Entry[] table 中,这样做可以尽量避免 hash 冲突。

参考B站黑马ThreadLocal视频

Java 常见并发容器

ConcurrentHashMap

HashMap 的线程安全版。(之前的集合里写过)

CopyOnWriteArrayList

  • 为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待
  • 通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。(将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了)
  • 读取操作没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
  • 写入操作 add()方法在添加集合的时候加了,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。

ConcurrentLinkedQueue

  • 这个队列使用链表作为其数据结构
  • Java 提供的线程安全的 Queue 可以分为阻塞队列非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue
  • 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。
  • ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全

BlockingQueue

  • 阻塞队列(BlockingQueue) 被广泛使用在“生产者-消费者”问题中,其原因是其提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
  • BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口
    3 个常见的 BlockingQueue 的实现类:ArrayBlockingQueue、LinkedBlockingQueue 、PriorityBlockingQueue

ArrayBlockingQueue

  • 有界队列实现类,底层采用数组来实现。默认非公平。
  • 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞

LinkedBlockingQueue

  • 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,具有更高的吞吐量,如果未指定,容量等于 Integer.MAX_VALUE。

PriorityBlockingQueue

  • 支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义方法来指定元素排序规则。
  • PriorityQueue 的线程安全版本。不可以插入 null 值。
  • 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列

ConcurrentSkipListMap

跳表

  • 跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。
  • 平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。
  • 高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。
  • 跳表的本质是同时维护了多个链表,并且链表是分层的,最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集
  • 跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的
  • 跳表是一种利用空间换时间的算法。
  • JDK 中实现这一数据结构的类是 ConcurrentSkipListMap

Atomic 原子类

  • 原子类说简单点就是具有原子操作特征的类,指一个操作是不可中断的
  • AtomicInteger 类主要利用 CAS (compare and swap) + volatilenative 方法来保证原子操作

参考总结自:JavaGuide 并发编程黑马ThreadLocal美团技术团队

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值