并发相关概念

文章目录


# 重点概念

1. JMM(Java内存模型)—— as-if-serial、happens-before、指令重排、原子性、可见性、有序性

2 锁

3 AQS(抽象的队列式的同步器)

4 线程

5 线程池

6 线程相关队列——线程等待队列、阻塞队列

7 JUC——CAS、原子类

8 线程工具——CountDownLatch、CyclicBarrier 、Semaphore 、Exchanger

9 ConcurrentHashMap

10 其他——CopyOnWriteArrayList、fail-safe 机制、fail-fast 机制

------------------------------------------------------------------------------------------------------------


# 重点概念

1 volatile 关键字

JMM 为 volatile 定义了一些特殊访问规则,当变量被定义为 volatile 后具备两种特性

  • 保证变量对所有线程可见
    • 当一条线程修改了变量值,新值对于其他线程来说是立即可以得知的。volatile 变量在各个线程的工作内存中不存在一致性问题,但 Java 的运算操作符并非原子操作,导致 volatile 变量运算在并发下仍不安全。
    • volatile可见性的实现:就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:
  • 写volatile时处理器会将缓存写回到主内存。
  • 一个处理器的缓存写回到内存会导致其他处理器的缓存失效。

相当于对缓存变量做了一次 store 和 write 操作,让 volatile 变量的修改对其他处理器立即可见

  • 🚩禁止指令重排序优化
    • 使用 volatile 变量进行写操作,汇编指令带有 lock 前缀,相当于一个内存屏障,后面的指令不能重排到内存屏障之前
    • 在每个volatile写操作的前面插入一个StoreStore屏障,防止写volatile与后面的写操作重排序。
    • 在每个volatile写操作的后面插入一个StoreLoad屏障,防止写volatile与后面的读操作重排序。
    • 在每个volatile读操作的后面插入一个LoadLoad屏障,防止读volatile与后面的读操作重排序。
    • 在每个volatile读操作的后面插入一个LoadStore屏障,防止读volatile与后面的写操作重排序。
    • 使用 lock 前缀引发两件事:① 将当前处理器缓存行的数据写回系统内存。②使其他处理器的缓存无效。

2 final 可以保证可见性吗?——(可以,写前store,读前load)

final 可以保证可见性,被 final 修饰的字段在构造方法中一旦被初始化完成,并且构造方法没有把 this 引用传递出去,在其他线程中就能看见 final 字段值。

在旧的 JMM 中,一个严重缺陷是线程可能看到 final 值改变。比如一个线程看到一个 int 类型 final 值为 0,此时该值是未初始化前的零值,一段时间后该值被某线程初始化,再去读这个 final 值会发现值变为 1。

为修复该漏洞,JSR-133 为 final 域增加重排序规则:只要对象是正确构造的(被构造对象的引用在构造方法中没有逸出),那么不需要使用同步就可以保证任意线程都能看到这个 final 域初始化后的值。

  • 写 final 域重排序规则

    禁止把 final 域的写重排序到构造方法之外,编译器会在 final 域的写后面,构造方法的 return 前,插入一个 Store屏障。确保在对象引用为任意线程可见之前,对象的 final 域已经初始化过。

  • 读 final 域重排序规则

    在一个线程中,初次读对象引用和初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作。编译器在读 final 域操作的前面插入一个 Load屏障,确保在读一个对象的 final 域前一定会先读包含这个 final 域的对象引用


3 synchronized关键字 ——(可重入阻塞式不公平锁、 monitorenter(获取monitor,+1)monitorexit(monitor释放,-1)

  • 底层实现
  • 每个 Java 对象都有唯一一个关联的 monitor使用 synchronized 时 JVM 会根据使用环境找到对象的 monitor,根据 monitor 的状态进行加解锁的判断。如果成功加锁就成为该 monitor 的唯一持有者,monitor 在被释放前不能再被其他线程获取。

  • 同步代码块使用 monitorenter(获取monitor)monitorexit(monitor释放) 这两个字节码指令获取和释放 monitor。这两个字节码指令都需要一个引用类型的参数指明要锁定和解锁的对象,

    • 对于同步普通方法,锁是当前实例对象;
    • 对于静态同步方法,锁是当前类的 Class 对象;
    • 对于同步方法块,锁是 synchronized 括号里的对象。
  • 执行 monitorenter 指令时,首先尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加 1,执行 monitorexit 指令时会将锁计数器减 1。一旦计数器为 0 锁随即就被释放。
    [例如]有两个线程 A、B 竞争 monitor,当 A 竞争到锁时会将 monitor 中的 owner 设置为 A,把 B 阻塞并放到等待资源的 ContentionList 队列。ContentionList 中的部分线程会进入 EntryList,EntryList 中的线程会被指定为 OnDeck 竞争候选者,如果获得了锁资源将进入 Owner 状态,释放锁后进入 !Owner 状态。被阻塞的线程会进入 WaitSet。
    被 synchronized 修饰的同步块对一条线程来说是**可重入(即某个线程已经获得某个锁,可以再次获取锁而不会出现死锁)**的,并且同步块在持有锁的线程释放锁前会阻塞其他线程进入。从执行成本的角度看,持有锁是一个重量级的操作。Java 线程是映射到操作系统的内核线程上的,如果要阻塞或唤醒一条线程,需要操作系统帮忙完成,不可避免用户态到核心态的转换。

  • 不公平的原因

所有收到锁请求的线程首先自旋(再试一次),如果通过自旋也没有获取锁将被放入 ContentionList,该做法对于已经进入队列的线程不公平。

  • 措施:为了防止 ContentionList 尾部的元素被大量线程进行 CAS 访问影响性能,Owner 线程会在释放锁时将 ContentionList 的部分线程移动到 EntryList 并指定某个线程为 OnDeck 线程,该行为叫做竞争切换,牺牲了公平性但提高了性能。

4 Lock接口的子类

  • AQS(Abstract Queued Synchronizer)

  • ReentrantLock:手动释放、可终端、可获取锁状态,适用于同步非常激烈的时候

  • ReentrantReadWriteLock:读写锁,读锁共享,写锁排他,具体描述如下:

    线程进入读锁的前提条件:

    • 没有其他线程的写锁,
    • 没有写请求或者有写请求,但调用线程和持有锁的线程是同一个:即线程持有锁之后可以在写请求的同时获取读锁

    线程进入写锁的前提条件:

    • 没有其他线程的读锁
    • 没有其他线程的写锁

    读写锁有以下三个重要的特性:

    • 公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
    • 重进入:读锁和写锁都支持线程重进入。
    • 锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
  • CountDownLatch:CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行

    • 内部原理:使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值变为0时,在CountDownLatch上await()的线程就会被唤醒。
    • 典型应用场景:1)启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行;2)实现多个线程开始执行任务的最大并行性,即使得多个线程在某一时刻同时开始执行。
    • 不足(和cyclicbraaier的最大区别):一次性的,计算器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch使用完毕后,它不能再次被使用。
  • Semphore:一种计数器,用来保护一个或者多个共享资源的访问。如果线程要访问一个资源就必须先获得信号量。如果信号量内部计数器大于0,信号量减1,然后允许共享这个资源;否则,如果信号量的计数器等于0,信号量将会把线程置入休眠直至计数器大于0.当信号量使用完时,必须释放。


5 Lock 、synchronized 区别?

在这里插入图片描述

  • 加锁机制Lock 并未用到 synchronized,而是利用了 volatile 的可见性
  • 类别:lock是一个接口,而synchronized是java的一个关键字。
  • 释放机制:synchronized在发生异常时会自动释放占有的锁,因此不会出现死锁;而lock发生异常时,不会主动释放占有的锁,必须手动来释放锁,可能引起死锁的发生。
  • 是否可中断:lock等待锁过程中可以用interrupt来中断等待,而synchronized只能等待锁的释放,不能响应中断;
  • 是否知道获取锁:Lock可以通过trylock来知道有没有获取锁,而synchronized不能;
  • 并发性能
    a. synchronized适用于在资源竞争不是很激烈、偶尔会有同步的情形下,原因在于,编译程序通常会尽可能的进行优化synchronize,另外可读性非常好。
    b. ReentrantLock适用于同步非常激烈的时候,原因在于它提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步等

6 ReentrantLock、synchronized

重入锁 ReentrantLock 是 Lock 最常见的实现,与 synchronized 一样可重入,不过它增加了一些高级功能:

  • 等待可中断(脾气更大): 持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待而处理其他事情。
  • 可为公平锁(更有原则): 公平锁指多个线程在等待同一个锁时,必须按照申请锁的顺序来依次获得锁,而非公平锁不保证这一点,在锁被释放时,任何线程都有机会获得锁。synchronized是非公平的,ReentrantLock 在默认情况下是非公平的,可以通过构造方法指定公平锁。一旦使用了公平锁,性能会急剧下降,影响吞吐量。
  • 🚩锁绑定多个条件: 一个 ReentrantLock 可以同时绑定多个 Condition。synchronized 中锁对象的 waitnotify 可以实现一个隐含条件,如果要和多个条件关联就不得不额外添加锁,而 ReentrantLock可以多次调用 newCondition 创建多个条件。

一般优先考虑使用 synchronized

  • ① synchronized 是语法层面的同步,足够简单
  • 自动释放更方便:Lock 必须确保在 finally 中释放锁,否则一旦抛出异常有可能永远不会释放锁。使用 synchronized 可以由 JVM 来确保即使出现异常锁也能正常释放 (自动释放锁)
  • 有记录方便管理:尽管 JDK5 时 ReentrantLock 的性能优于 synchronized,但在 JDK6 进行锁优化后二者的性能基本持平。从长远来看 JVM 更容易针对synchronized 优化,因为 JVM 可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 Lock 的话 JVM 很难得知具体哪些锁对象是由特定线程持有的。

7 ThreadLocal

ThreadLoacl 是线程共享变量主要用于一个线程内跨类、方法传递数据。ThreadLoacl 有一个静态内部类 ThreadLocalMap,其 Key 是 ThreadLocal 对象,值是 Entry 对象,Entry 中只有一个 Object 类的 vaule 值。ThreadLocal 是线程共享的,但 ThreadLocalMap 是每个线程私有的。ThreadLocal 主要有 setgetremove 三个方法。

  • set 方法
    首先获取当前线程,然后再获取当前线程对应的 ThreadLocalMap 类型的对象 map。如果 map 存在就直接设置值,key 是当前的 ThreadLocal 对象,value 是传入的参数。如果 map 不存在就通过 createMap 方法为当前线程创建一个 ThreadLocalMap 对象再设置值。
  • get 方法
    首先获取当前线程,然后再获取当前线程对应的 ThreadLocalMap 类型的对象 map。如果 map 存在就以当前 ThreadLocal 对象作为 key 获取 Entry 类型的对象 e,如果 e 存在就返回它的 value 属性。如果 e 不存在或者 map 不存在,就调用 setInitialValue 方法先为当前线程创建一个 ThreadLocalMap 对象然后返回默认的初始值 null。
  • remove 方法
    首先通过当前线程获取其对应的 ThreadLocalMap 类型的对象 m,如果 m 不为空,就解除 ThreadLocal 这个 key 及其对应的 value 值的联系。

存在的问题:解决措施都是调用remove清理

  • 线程复用会产生脏数据:由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用如果没有调用 remove 清理与线程相关的 ThreadLocal 信息,那么假如下一个线程没有调用 set 设置初始值就可能 get 到重用的线程信息。
  • ThreadLocal 还存在内存泄漏的问题:由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放因此需要及时调用 remove 方法进行清理操作。

1. JMM(Java内存模型)

1.1 JMM 作用 ——(定义访问规则、控制线程通信)

Java 线程的通信由 JMM 控制,JMM 的主要目的是定义程序中各种变量的访问规则变量包括实例字段、静态字段,但不包括局部变量与方法参数,因为它们是线程私有的,不存在多线程竞争。

JMM 遵循一个基本原则:只要不改变程序执行结果,编译器和处理器怎么优化都行。例如编译器分析某个锁只会单线程访问就消除锁,某个 volatile 变量只会单线程访问就把它当作普通变量

JMM 规定

  • 所有变量都存储在主内存,每条线程有自己的工作内存;
  • 工作内存中保存被该线程使用的变量的主内存副本;
  • 线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据
  • 不同线程间无法直接访问对方工作内存中的变量,线程通信必须经过主内存

关于主内存与工作内存的交互,即变量如何从主内存拷贝到工作内存、从工作内存同步回主内存,JMM 定义了 8 种原子操作


1.2 as-if-serial

不管怎么重排序,单线程程序的执行结果不能改变,编译器和处理器必须遵循 as-if-serial 语义。

为了遵循 as-if-serial,编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

as-if-serial 把单线程程序保护起来,给程序员一种幻觉:单线程程序是按程序的顺序执行的。


1.3 happens-before (先行发生原则)

先行发生原则,JMM 定义的两项操作间的偏序关系,是判断数据是否存在竞争的重要手段

JMM 将 happens-before 要求禁止的重排序按是否会改变程序执行结果分为两类。对于会改变结果的重排序 JMM 要求编译器和处理器必须禁止,对于不会改变结果的重排序,JMM 不做要求。

JMM 存在一些天然的 happens-before 关系,无需任何同步器协助就已经存在。

如果两个操作的关系不在此列,并且无法从这些规则推导出来,它们就没有顺序性保障,虚拟机可以对它们随意进行重排序

  • 程序次序规则:一个线程内写在前面的操作先行发生于后面的。
  • 管程锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。(先解锁后上锁)
  • volatile 规则:对 volatile 变量的写操作先行发生于后面的读操作(即随时写回主存)。
  • 线程启动规则:线程的 start 方法先行发生于线程的每个动作。
  • 线程终止规则:线程中所有操作先行发生于对线程的终止检测。
  • 对象终结规则:对象的初始化先行发生于 finalize 方法(对象被回收前调用的方法)。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C 。

1.4 as-if-serial 、happens-before

as-if-serial 保证单线程程序的执行结果不变,happens-before 保证正确同步的多线程程序的执行结果不变。

这两种语义的目的都是为了在不改变程序执行结果的前提下尽可能提高程序执行并行度


1.5 指令重排序

为了提高性能,编译器和处理器通常会对指令进行重排序,重排序指从源代码到指令序列的重排序,分为三种:

  • 编译器优化的重排序(编译器):编译器在不改变单线程程序语义的前提下可以重排语句的执行顺序。
  • 指令级并行的重排序(处理器):如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序

1.6 原子性、可见性、有序性

  • 原子性

    基本数据类型的访问都具备原子性,例外就是 long 和 double,虚拟机将没有被 volatile 修饰的 64 位数据操作划分为两次 32 位操作。

    如果应用场景需要更大范围的原子性保证,JMM 还提供了 lock 和 unlock 操作满足需求,尽管 JVM 没有把这两种操作直接开放给用户使用,但是提供了更高层次的字节码指令 monitorenter 和 monitorexit,这两个字节码指令反映到 Java 代码中就是 synchronized

  • 可见性

    可见性指当一个线程修改了共享变量时,其他线程能够立即得知修改。JMM 通过在变量修改后将值同步回主内存,在变量读取前从主内存刷新的方式实现可见性,无论普通变量还是 volatile 变量都是如此,区别是 volatile 保证新值能立即同步到主内存以及每次使用前立即从主内存刷新

    除了 volatile 外,synchronized 和 final 也可以保证可见性

    • 同步块可见性:由"对一个变量执行 unlock 前必须先把此变量同步回主内存,即先执行 store 和 write(即把工作内存的值传回store并写入write主存)"这条规则获得。
    • final 的可见性:被 final 修饰的字段在构造方法中一旦初始化完成,并且构造方法没有把 this 引用传递出去,那么其他线程就能看到 final 字段的值。
  • 有序性

    在本线程内观察所有操作是有序的,在一个线程内观察另一个线程,所有操作都是无序的。前半句指 as-if-serial 语义,后半句指指令重排序 和 工作内存与主内存延迟现象
    Java 提供 volatile 和 synchronized 保证有序性

    • volatile:本身就包含禁止指令重排序的语义
    • synchronized:保证一个变量在同一时刻只允许一条线程对其进行 lock 操作,确保持有同一个锁的两个同步块只能串行进入。

2. 锁


🚩补充:加锁的方式(可加锁结构)

对象、类、实例


2.1 锁优化

JDK 6 对 synchronized 做了很多优化,引入了自适应自旋、锁消除、锁粗化、偏向锁和轻量级锁等提高锁的效率,锁一共有 4 个状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁,状态会随竞争情况升级。锁可以升级但不能降级,这种只能升级不能降级的锁策略是为了提高锁获得和释放的效率


2.1.1 自旋锁 ——(让线程占用CPU执行等待n个循环,看能否等到锁释放 / 适用于锁占用时间短的情况)

同步对性能最大的影响是阻塞,挂起和恢复线程的操作都需要转入内核态完成。许多应用上共享数据的锁定只会持续很短的时间,为了这段时间去挂起和恢复线程并不值得。如果机器有多个处理器核心,我们可以让后面请求锁的线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁。为了让线程等待只需让线程执行一个忙循环,这项技术就是自旋锁。

自旋锁在 JDK1.4 就已引入,默认关闭,在 JDK6 中改为默认开启。自旋不能代替阻塞,虽然避免了线程切换开销,但要占用处理器时间,如果锁被占用的时间很短,自旋的效果就会非常好,反之只会白白消耗处理器资源。如果自旋超过了限定的次数仍然没有成功获得锁,就应挂起线程,自旋默认限定次数是 10。


2.1.2 自适应自旋 ——(自适应设定等待时间、甚至不等待)

JDK6 对自旋锁进行了优化,自旋时间不再固定,而是由前一次的自旋时间及锁拥有者的状态决定

如果在同一个锁上,自旋刚刚成功获得过锁且持有锁的线程正在运行,虚拟机会认为这次自旋也很可能成功,进而允许自旋持续更久。如果自旋很少成功,以后获取锁时将可能直接省略掉自旋,避免浪费处理器资源。

有了自适应自旋,随着程序运行时间的增长,虚拟机对程序锁的状况预测就会越来越精准。


2.1.3 锁消除 ——(单线程访问资源的锁消除)

锁消除指即时编译器对检测到不可能存在共享数据竞争的锁进行消除

主要判定依据来源于逃逸分析,如果判断一段代码中堆上的所有数据都只被一个线程访问,就可以当作栈上的数据对待,认为它们是线程私有的而无须同步。


2.1.4 锁粗化 ——(大家都要,干脆一起用)

原则需要将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中进行同步,这是为了使等待锁的线程尽快拿到锁。

但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之外的,即使没有线程竞争也会导致不必要的性能消耗。因此如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把同步的范围扩展到整个操作序列的外部。


2.1.5 偏向锁 ——(没人追我的话,我就偏爱你一个)

偏向锁是为了在没有竞争的情况下减少锁开销,锁会偏向于第一个获得它的线程,如果在执行过程中锁一直没有被其他线程获取,则持有偏向锁的线程将不需要进行同步。

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

一旦有其他线程尝试获取锁,偏向模式立即结束,根据锁对象是否处于锁定状态决定是否撤销偏向,后续同步按照轻量级锁那样执行。


2.2 轻量级锁、互斥量

轻量级锁为了在没有竞争的前提下减少重量级锁使用操作系统互斥量产生的性能消耗
互斥量:提供对资源的独占访问,只能为0/1,如果某一个资源同时只能允许一个访问者对其访问,可以使用互斥量控制线程对其访问。

在代码即将进入同步块时,如果同步对象没有被锁定,虚拟机将在当前线程的栈帧中建立一个锁记录空间,存储锁对象目前 Mark Word 的拷贝。然后虚拟机使用 CAS 尝试把对象的 Mark Word 更新为指向锁记录的指针,如果更新成功即代表该线程拥有了锁,锁标志位将转变为 00,表示处于轻量级锁定状态。

如果更新失败就意味着至少存在一条线程与当前线程竞争。虚拟机检查对象的 Mark Word 是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了锁,直接进入同步块继续执行,否则说明锁对象已经被其他线程抢占。如果出现两条以上线程争用同一个锁,轻量级锁就不再有效,将膨胀为重量级锁,锁标志状态变为 10,此时Mark Word 存储的就是指向重量级锁的指针,后面等待锁的线程也必须阻塞。

解锁同样通过 CAS 进行,如果对象 Mark Word 仍然指向线程的锁记录,就用 CAS 把对象当前的 Mark Word 和线程复制的 Mark Word 替换回来。假如替换成功同步过程就顺利完成了,如果失败则说明有其他线程尝试过获取该锁,就要在释放锁的同时唤醒被挂起的线程。


2.4 偏向锁、轻量级锁、重量级锁 ——(专一、快、多)

虚拟机控制锁的升级,一般来说优先使用偏向锁(适用于无竞争情况),当存在一定竞争则升级为轻量级锁(锁总是CAS、自旋等待,适用于🔒占用时间短的情况),最后升级为重量级锁

偏向锁:适用只有一个线程访问同步代码块的场景

  • 优点:加解锁不需要额外消耗,和执行非同步方法比仅存在纳秒级差距
  • 缺点:如果存在锁竞争会带来额外锁撤销的消耗

轻量级锁:适用追求响应时间、同步代码块执行快的场景。

  • 优点:是竞争线程不阻塞,程序响应速度快
  • 缺点:如果线程始终得不到锁会自旋消耗 CPU

重量级锁:适应追求吞吐量、同步代码块执行慢的场景。

  • 优点:线程竞争不使用自旋不消耗CPU
  • 缺点:是线程会阻塞,响应时间慢

2.5 读写锁

ReentrantLock 是排他锁,同一时刻只允许一个线程访问,读写锁在同一时刻允许多个读线程访问,在写线程访问时,所有的读写线程均阻塞。读写锁维护了一个读锁和一个写锁,通过分离读写锁使并发性相比排他锁有了很大提升

读写锁依赖 AQS(AbstractQueuedSynchronizer,抽象的队列式的同步器) 来实现同步功能,读写状态就是其同步器的同步状态。读写锁的自定义同步器需要在同步状态,即一个 int 变量上维护多个读线程和一个写线程的状态。读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。

  • 写锁(类似可重入机制):是可重入排他锁,如果当前线程已经获得了写锁则增加写状态,如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获得写锁的线程则进入等待。写锁的释放与 ReentrantLock 的释放类似,每次释放减少写状态,当写状态为 0 时表示写锁已被释放。

  • 读锁(类似可重入机制):是可重入共享锁,能够被多个线程同时获取,在没有其他写线程访问时,读锁总会被成功获取。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取则进入等待。读锁每次释放会减少读状态,减少的值是(1<<16),读锁的释放是线程安全的

  • 锁降级(耍赖皮):指把持住当前拥有的写锁,再获取读锁,随后释放先前拥有的写锁。从而保证写完一定能读回去

    锁降级中读锁的获取是必要的,这是为了保证数据可见性
    如果当前线程不获取读锁而直接释放写锁,假设此刻另一个线程 A 获取写锁修改了数据,当前线程无法感知线程 A 的数据更新。如果当前线程获取读锁,遵循锁降级的步骤,A 将被阻塞,直到当前线程使用数据并释放读锁之后,线程 A 才能获取写锁进行数据更新。



3 ❓AQS(抽象的队列式的同步器)

3.1 AQS ——(构建锁或其他同步组件的基础框架)

AQS(AbstractQueuedSynchronizer,抽象的队列式的同步器)
AQS 队列同步器是用来构建锁或其他同步组件的基础框架,它使用一个 volatile int state 变量作为共享资源,如果线程获取资源失败,则进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态:对同步状态进行更改需要使用同步器提供的 3个方法 getStatesetStatecompareAndSetState ,它们保证状态改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅定义若干同步状态获取和释放的方法,同步器既支持独占式也支持共享式。

同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。锁面向使用者,定义了使用者与锁交互的接口,隐藏实现细节;同步器面向锁的实现者,简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。


3.2 AQS 两种模式 ——(独占、共享)

独占模式:表示锁只会被一个线程占用,其他线程必须等到持有锁的线程释放锁后才能获取锁,同一时间只能有一个线程获取到锁。

  • 独占锁是只有头节点获取锁,其余节点的线程继续等待,等待锁被释放后,才会唤醒下一个节点的线程;
  • 独占锁的同步状态state值在0和1之间切换,保证同一时间只能有一个线程是处于活动的,其他线程都被阻塞,参考ReentranLock
  • 独占锁是一种悲观锁

共享模式:表示多个线程可获取同一个锁协同工作,ReadLock 就采用共享模式

  • 共享锁是只要头节点获取锁成功,就在唤醒自身节点对应的线程的同时,继续唤醒AQS队列中的下一个节点的线程,每个节点在唤醒自身的同时还会唤醒下一个节点对应的线程,以实现共享状态的“向后传播”,从而实现共享功能。
  • 共享锁的同步状态state值在整数区间内(自定义实现),如果state值<0则阻塞,否则不阻塞。参考ReadWriteLock、Semphore、CountDownLautch等。
  • 共享锁是一种乐观锁,允许多个线程同时访问共享资源。

3.3 AQS 独占式获取 / 释放锁的原理 ——(头节点唤醒机制)

获取同步状态时,调用 acquire 方法,维护一个同步队列,使用 tryAcquire 方法安全地获取线程同步状态,获取失败的线程会被构造同步节点并通过 addWaiter 方法加入到同步队列的尾部,在队列中自旋。之后调用 acquireQueued 方法使得该节点以死循环的方式获取同步状态,如果获取不到则阻塞,被阻塞线程的唤醒主要依靠前驱节点的出队或被中断实现,移出队列或停止自旋的条件是前驱节点是头结点且成功获取了同步状态(即必须轮着来)

释放同步状态时,同步器调用 tryRelease 方法释放同步状态,然后调用 unparkSuccessor 方法唤醒头节点的后继节点,使后继节点重新尝试获取同步状态。


3.4 为什么只有前驱节点是头节点时才能尝试获取同步状态?——(维护FIFO、避免过早唤醒)

头节点是成功获取到同步状态的节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。

目的是维护同步队列的 FIFO 原则,节点和节点在循环检查的过程中基本不通信,而是简单判断自己的前驱是否为头节点,这样就使节点的释放规则符合 FIFO,并且也便于对过早通知的处理,过早通知指前驱节点不是头节点的线程由于中断被唤醒。


3.5 AQS 共享式获取 / 释放锁的原理?——(唤醒机制的向后传播性)

获取同步状态时,调用 acquireShared 方法,该方法调用 tryAcquireShared 方法尝试获取同步状态,返回值为 int 类型,返回值不小于 0 表示能获取同步状态。因此在共享式获取锁的自旋过程中,成功获取同步状态并退出自旋的条件就是该方法的返回值不小于0(即谁都可以用)

只要头节点获取锁成功,就在唤醒自身节点对应的线程的同时,继续唤醒AQS队列中的下一个节点的线程,每个节点在唤醒自身的同时还会唤醒下一个节点对应的线程,以实现共享状态的“向后传播”,从而实现共享功能。

释放同步状态时,调用 releaseShared 方法,释放后会唤醒后续处于等待状态的节点。它和独占式的区别在于 tryReleaseShared 方法必须确保同步状态安全释放,通过循环 CAS 保证,因为释放同步状态的操作会同时来自多个线程。


4 线程

4.1 线程生命周期状态 ——(6种:new runnable blocked waiting time_waiting terminated)

  • NEW新建状态,线程被创建且未启动,此时还未调用 start 方法。
  • RUNNABLE:Java 将操作系统中的就绪和运行两种状态统称为 RUNNABLE,此时线程有可能在等待时间片,也有可能在执行。
  • BLOCKED阻塞状态,可能由于锁被其他线程占用、调用了 sleepjoin 方法、执行了 wait方法等。
    -WAITING等待状态,该状态线程不会被分配 CPU 时间片,需要其他线程通知或中断。可能由于调用了无参的 waitjoin 方法。
  • TIME_WAITING限期等待状态可以在指定时间内自行返回。导可能由于调用了带参的 waitjoin 方法。
  • TERMINATED终止状态,表示当前线程已执行完毕或异常退出。

4.2 线程创建方式 ——(3种:Thread-run()、Runnable-run()、Callable-call())

  • ① 继承 Thread 类+并重写 run 方法实现简单,但不符合里氏替换原则,不可以继承其他类。

    • 启动:在主函数中,new 出 MyThread1 类的实例。
    • 运行:调用 MyThread1 类的实例的 start() 方法即可。
    // 1、继承+重写
    public class MyThread1 extends Thread {
        public MyThread1(String name) {
            this.setName(name);
        }
        public void run() {
            //需要多线程运行的程序
        }
    }
    //2、main函数:启动(实例化)+运行(start)
    public class ThreadDemo1 {
        public static void main(String[] args) {
             MyThread1 myThread = new MyThread1("新线程");
             myThread.start();
        }
    }
    
  • ② 实现 Runnable 接口并重写 run 方法避免了单继承局限性,编程更加灵活,实现解耦。

    • 启动:
      在主函数中,new 出 MyThread1 类的实例
      new 出Thread 类(带有 target 的构造方法)
      把MyThread1 类的实例作为参数传入Thread 类的构造方法里
    • 运行:调用 Thread 类的实例的 start() 方法即可。
    // 1、实现+重写
    public class MyThread2 implements Runnable{
        public MyThread2(String name) {
            Thread.currentThread().setName(name);
        }
        public void run() {
            //需要多线程运行的程序
        }
    }
    //2、main函数:启动(实例化)+运行(start)
    public class ThreadDemo1 {
        public static void main(String[] args) {
             MyThread2 myThread = new MyThread2("新线程");
             Thread thread = new Thread(myThread);
             thread.start();
        }
    }
    
  • ③实现 Callable 接口并重写 call 方法可以获取线程执行结果的返回值,并且可以抛出异常。

    • 启动:
      new 出Callable 接口的实现类MyCallable
      new 出 FutureTask 类的实例 task ,把call() 方法的返回值放入FutureTask 类的构造方法里
      把 task 放入 new 出的 Thread 构造方法里
    • 运行:调用 Thread 类的实例的 start() 方法即可。
    // 1、实现+重写
    public class MyThread3 implements Callable<Integer> {
    		@Override
    		public Integer call() throws Exception {
    			    int i = 20;
    			    for(;i<=100;i++) {
    			        	System.out.println(Thread.currentThread().getName()+"..."+i);
    			    }
    			    return i;
    		}
    }
    	//2、main函数:启动+运行(start)
    public class ThreadDemo3 {
        public static void main(String[] args) {
    
            Callable<Integer> myThread3 = new MyThread3(); // new 出Callable 接口的实现类
            FutureTask<Integer> task = new FutureTask<Integer>(myThread3); // new 出 FutureTask 类的实例 task  ,把call() 方法的返回值放入FutureTask 类的构造方法里
    
            for (int i=1;i<=100;i++) {
                System.out.println(Thread.currentThread().getName()+"..."+i);
    
                if(i==30) {
    
                    Thread thread = new Thread(task,"新线程"); // 把 task 放入 new 出的 Thread 构造方法里
                    thread.start();
                }
            }
        }
    }
    

4.3 线程方法 ——(sleep-睡一会继续,yield-让步打回原形,join-让别人插个队,wait-挂起等人来叫)

sleep:当前线程进入休眠状态,与 wait 不同的是该方法不会释放锁资源(wait会),进入的是 TIMED-WAITING 状态
yiled :方法使当前线程让出 CPU 时间片给优先级相同或更高的线程,回到 RUNNABLE 状态,与其他线程一起重新竞争CPU时间片。
join :方法用于等待其他线程运行终止如果当前线程调用了另一个线程的 join 方法,则当前线程进入阻塞状态**,当另一个线程结束时当前线程才能从阻塞状态转为就绪态**,等待获取CPU时间片。底层使用的是wait,也会释放锁。
wait():方法的作用是将当前运行的线程挂起(即让其进入阻塞状态),直到notify或notifyAll方法来唤醒线程.


4.4 守护线程 ——(坚持服务到最后的线程)

任何一个守护线程都是整个JVM中所有非守护线程的保姆,只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。

Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。

值得一提的是,守护线程并非只有虚拟机内部提供,用户在编写程序时也可以自己设置守护线程。可以通过 setDaemon(true) 将线程设置为守护线程,但必须在线程启动前设置


4.5 线程通信方式有哪些 ——(同步、等待通知、管道通信(循环缓冲数组)、读写主存)

命令式编程中线程的通信机制有两种,共享内存和消息传递

  • 在共享内存的并发模型里:线程间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信
  • 在消息传递的并发模型里:线程间没有公共状态,必须通过发送消息来显式通信

Java 并发采用共享内存模型,线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

  • 读写主存-volatile: 告知程序任何对变量的读需要从主内存中获取,写必须同步刷新回主内存,保证所有线程对变量访问的可见性。

  • 同步-synchronized 确保多个线程在同一时刻只能有一个处于方法或同步块中,保证线程对变量访问的原子性、可见性和有序性。

  • wait/notify-等待通知机制:指一个线程 A 调用了对象的 wait 方法进入等待状态,另一线程 B 调用了对象的 notify/notifyAll 方法,线程 A 收到通知后结束阻塞并执行后序操作。对象上的 waitnotify/notifyAll 如同开关信号,完成等待方和通知方的交互。
    如果一个线程执行了某个线程的 join 方法,这个线程就会阻塞等待执行了 join 方法的线程终止,这里涉及等待/通知机制。join 底层通过 wait 实现,线程终止时会调用自身的 notifyAll 方法,通知所有等待在该线程对象上的线程。

  • 管道 IO 流:用于线程间数据传输,媒介为内存。PipedOutputStream 和 PipedWriter 是输出流,相当于生产者,PipedInputStream 和 PipedReader 是输入流,相当于消费者。管道流使用一个默认大小为 1KB 的循环缓冲数组。输入流从缓冲数组读数据,输出流往缓冲数组中写数据。当数组已满时,输出流所在线程阻塞;当数组首次为空时,输入流所在线程阻塞。

  • ThreadLocal 是线程共享变量,但它可以为每个线程创建单独的副本,副本值是线程私有的,互相之间不影响。


5 线程池

5.1 线程池好处 ——(消耗小、响应快、隔绝线程间影响)

  • 降低资源消耗,复用已创建的线程,提高响应速度,降低开销、控制最大并发数。
  • 隔离线程环境**,可以配置独立线程池,将较慢的线程与较快的隔离开,避免相互影响**。
  • 实现任务线程队列缓冲策略和拒绝机制。
  • 实现某些与时间相关的功能,如定时执行、周期执行等

5.2 🚩线程池处理任务的流程

① **【核心线程池未满】 / 【工作队列已满 & 线程数小于最大线程数】 **:创建一个新的线程执行任务,此时 workCount < corePoolSize。
【核心线程池已满 & 工作队列未满】:将线程存储在工作队列,此时 workCount >= corePoolSize。
如果超过最大线程数,按照拒绝策略来处理任务,此时 workCount > maximumPoolSize。
线程池创建线程时,会将线程封装成工作线程 Worker,Worker 在执行完任务后还会循环获取工作队列中的任务来执行。


5.3 线程池种类、创建方法

可以通过 Executors 的静态工厂方法创建线程池:

newFixedThreadPool(直接拉满)固定大小的线程池,核心线程数也是最大线程数,不存在空闲线程keepAliveTime = 0。该线程池使用的工作队列是无界阻塞队列 LinkedBlockingQueue,适用于负载较重的服务器

newSingleThreadExecutor(单线程处理),使用单线程,相当于单线程串行执行所有任务,适用于需要保证顺序执行任务的场景

newCachedThreadPool(来多少加多少),maximumPoolSize 设置为 Integer 最大值,是高度可伸缩的线程池。该线程池使用的工作队列是没有容量的 SynchronousQueue,如果主线程提交任务的速度高于线程处理的速度,线程池会不断创建新线程,极端情况下会创建过多线程而耗尽CPU 和内存资源。适用于执行很多短期异步任务的小程序或负载较轻的服务器

newScheduledThreadPool(定期执行+不回收):线程数最大为 Integer 最大值,存在 OOM 风险。支持定期及周期性任务执行,适用需要多个后台线程执行周期任务,同时需要限制线程数量的场景。相比 Timer 更安全,功能更强,与 newCachedThreadPool 的区别是不回收工作线程

newWorkStealingPool(多队列):JDK8 引入,创建持有足够线程的线程池支持给定的并行度,通过多个队列减少竞争。


5.4 线程池相关方法

1、execute() – 提交任务
2、addWorker() – 添加worker线程
3、runWorker() – 执行任务
4、getTask() – 获取任务
5、processWorkerExit() – worker线程退出


5.5 七大线程池参数

corePoolSize(最小线程数,可为0)常驻核心线程数,如果为 0,当执行完任务没有任何请求时会消耗线程池;如果大于 0,即使本地任务执行完,核心线程也不会被销毁。该值设置过大会浪费资源,过小会导致线程的频繁创建与销毁。

maximumPoolSize(最大线程数,>1):线程池能够容纳同时执行的线程最大数,必须大于等于 1,如果与核心线程数设置相同代表固定大小线程池。

keepAliveTime(最大空闲时间):线程空闲时间,线程空闲时间达到该值后会被销毁,直到只剩下 corePoolSize 个线程为止,避免浪费内存资源。

unit:keepAliveTime 的时间单位。

workQueue工作队列当线程请求数大于等于 corePoolSize 时线程会进入阻塞队列

threadFactory线程工厂,用来生产一组相同任务的线程。可以给线程命名,有利于分析错误

handler拒绝策略,默认使用 AbortPolicy 丢弃任务并抛出异常,CallerRunsPolicy 表示重新尝试提交该任务,DiscardOldestPolicy 表示抛弃队列里等待最久的任务并把当前任务加入队列,DiscardPolicy 表示直接抛弃当前任务但不抛出异常。


5.6 如何关闭线程池 ——(shutdown(关没执行的)shutdownNow(关全部)

原理:可以调用 shutdownshutdownNow 方法关闭线程池,原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法中断线程无法响应中断的任务可能永远无法终止

区别

  • shutdown(常用):只是将线程池的状态设置为SHUTWDOWN状态,正在执行的任务会继续执行下去,没有被执行的则中断。
  • shutdownNow:则是将线程池的状态设置为STOP,正在执行的任务则被停止,没被执行任务的则返回适用于任务可不用执行完的情况

通常调用 shutdown 来关闭线程池,如果任务不一定要执行完也可调用 shutdownNow


5.7 线程池选择策略

可以从以下角度分析:

  • ①任务性质:CPU 密集型、IO 密集型和混合型。

性质不同的任务可用不同规模的线程池处理

  • CPU 密集型任务应配置尽可能小的线程,如配置 N_cpu+1 个线程的线程池。
  • IO 密集型任务线程并不是一直在执行任务,应配置尽可能多的线程,如 2*N_cpu
  • 混合型的任务:
    • 只要两个任务执行的时间相差不大可以拆分,将其拆分为一个 CPU 密集型任务和一个 IO 密集型任务,那么分解后的吞吐量将高于串行执行的吞吐量
    • 如果相差太大则没必要分解。
  • ②任务优先级。

优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 处理

  • ③任务执行时间。

执行时间不同的任务可以交给不同规模的线程池处理或者使用优先级队列让执行时间短的任务先执行

  • ④任务依赖性:是否依赖其他资源,如数据库连接。

依赖数据库连接池的任务,由于线程提交 SQL 后需要等待数据库返回的结果,等待的时间越长 CPU 空闲的时间就越长,因此线程数应该尽可能地设置多一些,提高 CPU 的利用率。


6 线程相关队列

6.1 线程等待队列 ——(双向链表)

每当有新线程请求资源时都会进入一个等待队列,只有当持有锁的线程释放锁资源后该线程才能持有资源。等待队列通过双向链表实现,线程被封装在链表的 Node 节点中

Node 的等待状态包括:
CANCELLED(线程已取消)
SIGNAL(线程需要唤醒)
CONDITION (线程正在等待)
PROPAGATE(后继节点会传播唤醒操作,只在共享模式下起作用)


6.2 🚩阻塞队列有哪些选择? 阻塞实现原理?

Java 中的阻塞队列

  • ArrayBlockingQueue,由数组组成有界阻塞队列,默认情况下不保证线程公平,有可能先阻塞的线程最后才访问队列。

  • LinkedBlockingQueue,由**链表结构组成的有界阻塞队列,队列的默认和最大长度为 Integer 最大值**。

  • PriorityBlockingQueue,支持优先级的无界阻塞队列,默认情况下元素按照升序排序。可自定义 compareTo 方法指定排序规则,或者初始化时指定 Comparator 排序,不能保证同优先级元素的顺序。

  • DelayQueue,支持延时获取元素的无界阻塞队列,使用优先级队列实现。创建元素时可以指定多久才能从队列中获取当前元素,只有延迟期满时才能从队列中获取元素,适用于缓存和定时调度

  • SynchronousQueue不存储元素的阻塞队列,每一个 put 必须等待一个 take。默认使用非公平策略,也支持公平策略,适用于传递性场景,吞吐量高

  • LinkedTransferQueue链表组成无界阻塞队列,相对于其他阻塞队列多了 tryTransfertransfer 方法。

    transfer方法:如果当前有消费者正等待接收元素,可以把生产者传入的元素立刻传输给消费者,否则会将元素放在队列的尾节点并等到该元素被消费者消费才返回。
    tryTransfer 方法用来试探生产者传入的元素能否直接传给消费者,如果没有消费者等待接收元素则返回 false,和 transfer 的区别是无论消费者是否消费都会立即返回。

  • LinkedBlockingDeque链表组成双向阻塞队列,可从队列的两端插入和移出元素,多线程同时入队时减少了竞争

阻塞实现原理
如果工作队列不可用,阻塞生产者主要通过 LockSupport 的 park(停) 方法实现,不同操作系统中实现方式不同



7 JUC(java.util.concurrent 并发编程包)

7.1 CAS

7.1.1 CAS 操作 ——(比较并交换操作)

CAS 表示 Compare And Swap,比较并交换

CAS 需要三个操作数,分别是内存位置 V、旧的预期值 A 和准备设置的新值 B。CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不执行更新。但不管是否更新都会返回 V 的旧值,这些处理过程是原子操作,执行期间不会被其他线程打断

在 JDK 5 后,Java 类库中才开始使用 CAS 操作,该操作由 Unsafe 类里的 compareAndSwapInt 等几个方法包装提供。HotSpot 在内部对这些方法做了特殊处理,即时编译的结果是一条平台相关的处理器 CAS 指令。Unsafe 类不是给用户程序调用的类,因此 JDK9 前只有 Java 类库可以使用 CAS,譬如 juc 包里的 AtomicInteger类中 compareAndSet 等方法都使用了Unsafe 类的 CAS 操作实现。


7.1.2 CAS怎么保证原子性

  • 如何保证get()方法返回的是内存中最新值?value被volatile修饰,volatile保证了可见性,自然是最新值
  • 如何保证原子性操作的
    • (1)通过this+valueOff获取当前主存中的最新值
    • (2)将最新值和expect进行比较,如果值相等,将this+valueoff对应的值修改为update,并返回true,如果不等则返回false。即只有当compareAndSet方法中的get()当前读到的值和主存中的实际值相等时才可以修改,这样就保障了原子性。

7.1.3 CAS 存在问题 ——(ABA逻辑漏洞)

CAS 从语义上来说存在一个逻辑漏洞 ABA 问题若操作中初始值V =最终值A,则忽略中间改变过程,认为没有改变过。事实上,可能存在V—B—A。

措施:juc 包提供了一个 AtomicStampedReference,原子更新带有版本号的引用类型,通过控制变量值的版本来解决 ABA 问题。大部分情况下 ABA 不会影响程序并发的正确性,如果需要解决,传统的互斥同步可能会比原子类更高效。


7.2 原子类 ——(基本类型、数组、引用Reference、字段ReferenceField)

JDK 5 提供了 java.util.concurrent.atomic 包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。到 JDK 8 该包共有17个类,依据作用分为四种:

  • 原子更新基本类型类:AtomicInteger 原子更新整形、 AtomicLong 原子更新长整型、AtomicBoolean 原子更新布尔类型

  • 原子更新数组类:AtomicIntegerArray,原子更新整形数组里的元素、 AtomicLongArray 原子更新长整型数组里的元素、AtomicReferenceArray 原子更新引用类型数组里的元素。

  • 🚩原子更新引用类:AtomicReference 原子更新引用类型、AtomicMarkableReference 原子更新带有标记位的引用类型,可以绑定一个 boolean 标记、 AtomicStampedReference 原子更新带有版本号的引用类型,关联一个整数值作为版本号,解决 ABA 问题。

  • 🚩原子更新字段类:AtomicIntegerFieldUpdater 原子更新整形字段的更新器、 AtomicLongFieldUpdater 原子更新长整形字段的更新器AtomicReferenceFieldUpdater 原子更新引用类型字段的更新器。


7.3 AtomicIntger 实现原子更新的原理

getAndIncrement 以原子方式将当前的值加 1

  • 首先在 for 死循环中取得 AtomicInteger 里存储的数值
  • 第二步对 AtomicInteger 当前的值加 1
  • 第三步调用 compareAndSet 方法进行原子更新,先检查当前数值是否等于 expect,如果等于则说明当前值没有被其他线程修改,则将值更新为 next,否则会更新失败返回 false,程序会进入 for 循环重新进行 compareAndSet 操作。

atomic 包中只提供了三种基本类型的原子更新,atomic 包里的类基本都是使用 Unsafe 实现的,Unsafe 只提供三种 CAS 方法:compareAndSwapIntcompareAndSwapLongcompareAndSwapObject,例如原子更新 Boolean 是先转成整形再使用 compareAndSwapInt


8 线程工具

8.1 CountDownLatch ——(闭锁实现方式,阻塞多个线程)

CountDownLatch 是基于执行时间的同步类,允许一个或多个线程等待其他线程完成操作,构造方法接收一个 int 参数作为计数器,如果要等待 n 个点就传入 n。每次调用 countDown 方法时计数器减 1,await 方法阻塞当前线程直到计数器变为0,由于 countDown 方法可用在任何地方,所以 n 个点既可以是 n 个线程也可以是一个线程里的 n 个执行步骤。


8.2 CyclicBarrier ——(栅栏/循环屏障,设置多个线程公共点)

循环屏障是基于同步到达某个点的信号量触发机制,作用是让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障才会解除(让线程达到某个公共点)。构造方法中的参数表示拦截线程数量,每个线程调用 await 方法告诉 CyclicBarrier 自己已到达屏障,然后被阻塞。还支持在构造方法中传入一个 Runnable 任务,当线程到达屏障时会优先执行该任务。适用于多线程计算数据,最后合并计算结果的应用场景。

CountDownLacth 的计数器只能用一次,而 CyclicBarrier 的计数器可使用 reset 方法重置,所以 CyclicBarrier 能处理更为复杂的业务场景,例如计算错误时可用重置计数器重新计算。


8.3 Semaphore ——(信号量,用于流量控制)

信号量用来控制同时访问特定资源的线程数量通过协调各个线程以保证合理使用公共资源。信号量可以用于流量控制,特别是公共资源有限的应用场景,比如数据库连接。

  • Semaphore 的构造方法参数接收一个 int 值,表示可用的许可数量即最大并发数。使用 acquire 方法获得一个许可证,使用 release 方法归还许可,还可以用 tryAcquire 尝试获得许可。

8.4 Exchanger ——(交换器,用于线程间数据交换)

交换者是用于线程间协作的工具类,用于进行线程间的数据交换。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据

两个线程通过 exchange 方法交换数据,第一个线程执行 exchange 方法后会阻塞等待第二个线程执行该方法,当两个线程都到达同步点时这两个线程就可以交换数据,将本线程生产出的数据传递给对方。应用场景包括遗传算法、校对工作等。


9 ConcurrentHashMap

9.1 ❓JDK7 的 ConcurrentHashMap 原理?——锁分段提高效率

ConcurrentHashMap 用于解决 HashMap 的线程不安全和 HashTable 的并发效率低

HashTable 效率低原因:所有线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器的部分数据,那么多线程访问容器不同数据段的数据时,线程间就不会存在锁竞争,从而有效提高并发效率,这就是 ConcurrentHashMap 的锁分段技术

🚩ConcurrentHashMap 的锁分段技术

  • 首先将数据分成 Segment 数据段

  • Q然后给每一个数据段配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。

  • get: 实现简单高效,先经过一次再散列,再用这个散列值通过散列运算定位到 Segment,最后通过散列算法定位到元素。get 的高效在于不需要加锁,除非读到空值才会加锁重读。get 方法中将共享变量定义为 volatile,在 get 操作里只需要读所以不用加锁

  • put必须加锁,首先定位到 Segment,然后进行插入操作,第一步判断是否需要对 Segment 里的 HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放入数组。

  • size: 操作用于统计元素的数量,必须统计每个 Segment 的大小然后求和(各部分之和),在统计结果累加的过程中,之前累加过的 count 变化几率很小,因此先尝试两次通过不加锁的方式统计结果,如果统计过程中容器大小发生了变化,再加锁统计所有 Segment 大小。判断容器是否发生变化根据 modCount 确定。


9.2 JDK8 的 ConcurrentHashMap 原理?——(哈希槽、链表转红黑树)

主要对 JDK7 做了三点改造:

  • 取消分段锁机制,使用多个哈希槽,进一步降低冲突概率。
  • ② 引入红黑树结构,同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构。
  • ③ 使用了更加优化的方式统计集合内的元素数量。

具体优化表现在:在 put、resize 和 size 方法中设计元素总数的更新和计算都避免了锁,使用 CAS 代替。

  • get 同样不需要同步,put 操作时如果没有出现哈希冲突,就使用 CAS 添加元素,否则使用 synchronized 加锁添加元素。

  • 当某个槽内的元素个数达到 7 且 table 容量不小于 64 时,链表转为红黑树。当某个槽内的元素减少到 6 时,由红黑树重新转为链表。在转化过程中,使用同步块锁住当前槽的首元素,防止其他线程对当前槽进行增删改操作,转化完成后利用 CAS 替换原有链表

  • 链表转为红黑树的方式:由于 TreeNode 节点也存储了 next 引用,因此红黑树转为链表很简单,只需从 first 元素开始遍历所有节点,并把节点从 TreeNode 转为 Node 类型即可,当构造好新链表后同样用 CAS 替换红黑树


10 其他

10.1 🚩ArrayList 的线程安全集合是什么?——(CopyOnWriteArrayList )

可以使用 CopyOnWriteArrayList 代替 ArrayList,它实现了读写分离

写操作复制一个新的集合,在新集合内添加或删除元素,修改完成后再将原集合的引用指向新集合

好处:可以高并发地进行读写操作而不需要加锁,因为当前集合不会添加任何元素。使用时注意尽量设置容量初始值,并且可以使用批量添加或删除,避免多次扩容,比如只增加一个元素却复制整个集合。

适合读多写少的情况,单个添加时效率极低。

CopyOnWriteArrayList 是 fail-safe 的,并发包的集合都是这种机制


10.2 fail-safe 机制和 fail-fast 机制

fail-safe 机制保证任何对集合结构的修改都会在一个复制的集合上进行,因此不会抛出 java.util.ConcurrentModificationException 异常。

fail-safe机制产生的两个问题:

  • 复制集合会产生大量临时对象,增加系统开销
  • 无法保证读取的数据是目前原始数据结构中的数据

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值