Java并发那些事儿

操作系统级的并发策略

多核CPU与主内存的层次结构

多核CPU_三级缓存_主内存的层次结构

啥是超线程?

一个ALU对应多个PC|Registers,即复用ALU强大的计算能力

缓存与缓存行

缓存行(Cache Line)对齐原理
一个缓存行通常占用64byte。这意味着,如果可以在代码中合理添加padding,使得缓存行对齐,就可以减少并发问题(伪共享),提高效率

应用

基于多核CPU和主内存的层次结构,以及缓存行的这种模型,我们可以尝试在应用层代码中添加padding,从而减少并发问题。在Java领域中,有以下经典的应用:

  • Disruptor框架
  • Java容器LinkedTransferQueue,添加多个long类型变量来补位

系统底层如何实现数据一致性

  1. 先用缓存一致性协议(如MESI:modified、exclusive、shared、invalid)
  2. 如果不行,就锁总线(也有的优化方案,选择先锁缓存)

系统底层如何保证有序性

  1. sfence mfence lfence等系统原语(但不通用)

    SFENCE:在该指令前的写操作必须在该指令后的写操作前完成

    LFENCE:在该指令前的读操作必须在该指令后的读操作前完成

    MFENCE:在该指令前的读写操作必须在该指令后的读写操作前完成

  2. 锁总线

【扩展】缓存一致性协议

其实Java内存模型是参考了处理器内存模型来的。处理器处理速度很快,内存操作速度太慢,于是中间就多了一个缓存,来解决这种速度矛盾。但是这也带来了缓存一致性问题。

为了解决这个问题,处理器在访问缓存时,需要遵守一定的协议,如MESI协议。

MESI含义:

Java线程与线程池

线程的实现方式

  1. 内核线程
    程序一般不会直接使用内核线程,而是使用内核线程(Kenel-Level Thread, KLT)的一种高级接口——轻量级进程(Light Weight Process, LWP)
  2. 用户线程
  3. 混合用户线程和轻量级进程

线程调度方式

  1. 协同式调度
  2. 抢占式调度
    Java线程调度方式

Java线程状态及转换

Java线程状态变迁

线程池工作原理

线程池处理任务流程

线程池相关参数

ThreadPoolExecutor执行示意图

  • 核心线程数(corePoolSize)
  • 最大线程数(maximumPoolSize)
  • 超时时间(keepAliveTime,unit)
  • 任务队列(workQueue)
  • 线程工厂(threadFactory)

线程池使用策略

先要考虑任务特性,主要从以下几个方面来考虑

  • 任务性质:CPU密集型、IO密集型、混合型
  • 任务优先级
  • 任务执行时间
  • 任务依赖性

举例:

  • CPU密集型任务,瓶颈在于系统CPU,因此线程不宜过多,如 N+1个线程(N代表CPU数量)
  • IO密集型任务,瓶颈在于等待IO响应,因此可能会存在大量的等待,此时可以配置较多线程,如 N*2
  • 混合型任务,可以拆分成多个单一职能的任务
  • 优先级不同任务,可以使用优先队列(PriorityBlockingQueue)来处理

Java内存模型

为什么要有Java内存模型

Java虚拟机通过JMM(Java内存模型)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

JMM由JVM来实现保证。

Java内存模型运行机制

Java内存模型

Java内存模型如上图,具体运行机制如下:

  • Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本。
  • 线程对变量的所有操作都必须再工作内存中进行。
  • 不同线程间的变量访问变更,均需要通过主内存来进行。

正式基于有这样的模型设计,才有了Java的并发问题——共享变量如何正确被多个线程访问操作?

内存间的交互操作

上述的Java内存模型交互图中,还标明了一些操作。事实上,Java内存模型中规定了8种原子操作,来进行内存间的交互操作。

  • lock(锁定)
  • unlock(解锁)
  • read(读取)
  • load(载入)
  • use(使用)
  • assign(赋值)
  • store(存储)
  • write(写入)

as-if-serial

编译器和处理器都必须遵守as-if-serial语义。

那么啥是as-if-serial语义呢?

简而言之,编译器和处理器不会对存在数据依赖的操作(两个操作访问同一个变量,且其中有一个操作为写,即存在数据依赖性)做重排序。

as-if-serial语义保证了程序在单线程执行时“有序”(可能存在重排序),即结果一致。

happens-before

happens-before是一套用来定义两个操作间执行顺序的规则。它是JMM中的核心概念,是判断数据是否存在竞争、线程是否安全的主要依据。

它的主要规则如下:

  • 程序次序规则(Program Order Rule):as-if-serial语义的“封装”
  • 管程锁定规则(Monitor Lock Rule)
  • volatile变量规则(Volatile Variable Rule)
  • 线程启动规则(Thread Start Rule)
  • 线程终止规则(Thread Termination Rule)
  • 线程中断规则(Thread Interruption Rule)
  • 对象终结规则(Finalizer Rule)
  • 传递性(Transitivity)
为什么会有happens-before?

单线程内程序的运行是“有序”(结果一致)的,这个可以根据as-if-serial语义,通过编译器和处理器来保证。但多线程内的程序如何保证这种“有序”呢?JMM通过一套规则来保证,而这套规则就叫做 happens-before(先行发生原则)。

JMM有以下特性:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前
  • 两个操作存在happens-before关系,但并不意味着具体实现必须按照happens-before关系指定的顺序来执行。如果重排序后的执行结果,与按happens-before关系来执行的结果一致,JMM也允许这种重排序。

针对第二点,可以参见下图:
happens-before规则与JMM重排序

Java线程安全的实现方式

1. 阻塞同步

悲观的并发策略
Synchronized(原生语法层)、ReentrantLock(更多的体现在API层)

2. 非阻塞同步

基于冲突检测的乐观并发策略(CAS)如:AtomicReference

ABA问题(带版本号)如:AtomicStampedReference

减少CAS冲突发生,如:LongAdder

CAS底层是 lock cmpxchg 指令
lock保证了原子性

3. 无同步方案

3.1 可重入代码

就是幂等。

一段代码,能保证入参不变,结果固定,就是可冲入的。

一般这种代码不依赖存储在堆上的数据、公用的系统资源。

3.2 线程本地存储

ThreadLocal内存结构
ThreadLocal内存结构

相当于线程本地的 全局变量

为什么不用static?
可能会有线程同步问题

弱引用在 ThreadLocal 中的应用

ThreadLocalMap 中的 Entry 继承自 WeakReference,它的 key,即 ThreadLocal,使用了弱引用。

若是使用强引用,即使在线程中设置了 threadLocal=null,但是key的强引用依然指向ThreadLocal对象,所以会有内存泄露问题(即ThreadLocal不能释放),而使用弱引用则不会有这个问题

脏数据问题

线程复用会产生脏数据,由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用。如果没有调用 remove 清理与线程相关的 ThreadLocal 信息,那么假如下一个线程没有调用 set 设置初始值就可能 get 到重用的线程信息

内存泄露问题

ThreadLocal 还存在内存泄漏的问题,由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放。因此需要及时调用 remove 方法进行清理操作。

应用举例
SimpleDateFormat线程不安全

ThreadLocal的应用

  • Spring @Transactional注解中,Connection是放在 ThreadLocal中的,这样取到的永远是同一个Connection

Synchronized

synchronized锁类型

修饰普通方法时,锁的是当前类对象实例
修饰静态方法时,锁的是当前类的Class对象
修饰代码块时,锁的是Synchronized括号里的对象

底层实现

  1. Java代码层级:synchronized
  2. 字节码层级:

    monitorenter && moniterexit(修饰代码块)

    flags:ACC_SYNCHRONIZED(修饰方法)

  3. 执行过程中,锁自动升级(无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁)
  4. 汇编层级:lock cmpxchg

Java对象内存布局

Java对象的内存布局

Java对象在内存中的布局可以分为三个区域:

  1. 对象头(Header)
    对象头主要包括以下信息:

    • Mark Word,用于存储对象自身运行时数据。如:hashCode、GC分代年龄、锁状态标识等。
    • 类型指针(Class Pointer),即指向该对象类元数据的指针(类数据保存在方法区哦)
    • 如果对象是一个数组,那么还需要在对象头存储数据长度的数据(Array Length)
  2. 实例数据(Instance Data)
    即对象真正存储的有效信息,如byte/int/long/double/reference
  3. 对齐填充(Padding)
    HotSpot虚拟机要求对象的内存地址必须是8字节的整数倍,因此需要对齐填充。

Mark Word的存储结构

Mark Word的存储结构

锁升级过程

  • 当一个线程访问同步块时,会先判断当前线程是否持有偏向锁,即对象头的Mark Word中是否存了当前线程的ID。如果是,则表示获取锁成功,可以继续执行;否则,则需要判断当前是否是偏向锁(偏向锁标识为1)
  • 如果当前还不是偏向锁,则使用CAS竞争锁。如果已经为偏向锁,则升级为轻量级锁进行竞争。
  • 轻量级锁竞争时,线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针(线程在执行同步块之前,会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录中,官方称为 Displaced Mark Word)。如果成功,则当前线程获得锁;如果失败,则表示存在竞争,当前线程自旋
  • 轻量级线程自旋一定次数后,如果依然失败,则锁会升级为重量级锁。此时其他所有尝试获取锁的线程都会被阻塞住。只有持有锁的线程释放锁之后,其他线程才会被唤醒,继续进行锁的竞争。

无锁

Java8默认对象头是无锁状态。Mark Word中主要记录对象的hashCode和分代年龄。

偏向锁

加偏向锁的过程,实际上是将对象Mark Word中的线程ID改为当前线程的ID

偏向锁默认有个延时配置,默认是4秒。

Why?

因为JVM虚拟机自己有一些默认启动的线程,里面有很多synchorized代码。这些代码启动的时候就知道肯定会有竞争。如果启动时直接就开启偏向锁,那么就会造成偏向锁不断进行锁撤销和锁升级的操作,效率较低。

偏向锁延时配置参数如下:

-XX:BiasedLockingStartupDelay=0

偏向锁不可重新偏向、批量偏向、批量撤销

轻量级锁

线程在自己的线程栈帧生成 锁记录(Lock Record),然后用CAS将对象Mark Word设置为指向自己这个线程的 锁记录 的指针。设置成功者得到轻量级锁。

如果线程获取轻量级锁失败,会进行一定次数的自旋。JDK1.6之后,加入了自适应自旋(Adaptive Self Spinning)的功能,由JVM自己控制自旋次数。

配置自旋次数:

-XX:PreBlockSpin

重量级锁

向操作系统申请资源(如 linux mutex),CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射会用户空间。

对于一个Java开发者来说,synchronized重量级锁的工作机制,可以看下图所示:

Synchronized锁的工作机制

synchronized可以配合wait/notify,实现等待通知机制。其实现原理,可以参见笔者的另一篇文章:Java等待通知机制的工作原理

Volatile

引入——DCL单例问题

需要加volatile关键词,防止new对象(非原子操作)时指令重排序,导致取值错误

使用注意事项

如果用volatile修饰一个引用对象,如果引用对象指向的那个值变更了,volatile仍然不能保证这种可见性。(它只能保证这个引用值的可见性!)

底层实现

  1. Java代码层:volatile
  2. 字节码层:ACC_VOLATILE
  3. JVM层:内存屏障
    屏障两边的指令不可以重排!保证执行的有序性!
  4. hotspot的实现
    bytecodeinterpreter.cpp
    orderaccess_linux_x86.inline.cpp
    lock; addl

两个特性

  1. 保证可见性
    JMM内存模型的读写模式,导致一个线程对共享变量的改动,可能不会立即对另一个线程可见,从而导致出现不符合程序员预期的结果。
  2. 禁止指令重排序

    编译器和处理器一般都遵守as-if-serial语义,因此单线程的程序,即使发生了重排序,最终结果也是符合预期的。

    但在多线程程序中,则没有这样的保证。

如何保证可见性

类似于MESI这样的缓存一致性协议,JMM中针对volatile修饰的变量,有如下的内存语义:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中共享变量刷新到主内存;
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,从而使用主内存进行读取。

总结起来,就是该变量的读写都必须通过主内存,因此都是“可见”的。

如何禁止指令重排序

volatile禁止指令重排序,使用了内存屏障。
屏障两边的指令不可以重排!保证执行的有序性!

JSR内存屏障分类(4种,load/save两两组合)

JMM内存屏障插入策略,可以总结为以下:

  • StoreStoreBarrier

    Volatile写操作
    StoreLoadBarrier

  • Valatile读操作

    LoadLoadBarrier

    LoadStoreBarrier

即编译器不可对volatile写与volatile写前面的操作进行重排序,也不可能对volatile读与volatile读后面的操作重排序

final

底层实现

  • 写final域的重排序规则要求编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障。
  • 读final域的重排序规则要求编译器在final域的读之前,插入一个LoadLoad屏障
但如果处理器级别就已经能够保证这种特性,则可以优化省略这些屏障

使用注意事项

final引用不能从构造函数移除

AQS

AbstractQueuedSynchronizer是相当多Java并发类的基础。

jdk concurrent包下一个共同的套路如下:

  • 声明共享变量为volatile
  • 使用CAS的原子条件更新来实现线程之间的同步

配合volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信

Concurrent包实现原理

AQS同步队列的基本结构

AQS同步队列的基本结构

独占式同步状态获取流程

独占式同步状态获取流程

超时独占式同步状态获取流程

超时独占式同步状态获取流程

ReentrantLock

特性

  • 中断响应 lockInterruptibly
  • 公平锁(默认非公平锁)
  • 限时等待

API

ReentrantLock.lock();
ReentrantLock.unlock();

底层原理

底层使用CAS来实现,详情请看 AQS原理

Condition

实际上就是等待队列,配合ReentrantLock使用。与synchronized+wait/notify/notifyAll类似。
不同的是ReentrantLock可以new多个Condition,即创造多个等待队列,然后分别唤醒不同的等待队列的线程(更加灵活)。

工作原理

ReentrantLock + Condition的等待通知模型的基本结构

ReentrantLock + Condition的等待通知模型的基本结构

当前线程加入等待队列

当前线程加入等待队列

节点从等待队列移动到同步队列

节点从等待队列移动到同步队列

LockSupport

线程阻塞和唤醒功能
LockSupport.park(); // 线程当前线程
LockSupport.unpark(t); // 唤醒线程t

并发工具

CountDownLatch

线程间的计数等待
CountDownLatch.countDown(); // -1
CountDownLatch.await(); // 等待达到0

使用 AbstractQueuedSynchronizer 实现

CyclicBarrier

循环栅栏。让所有线程达到一个同步点后,再继续运行(类比 人满发车)
支持重置计数器
CyclicBarrier.await(); // await表示达到那个同步点
CyclicBarrier.reset(); // 重置计数器

使用 ReentrantLock + Condition 控制访问

Phaser

多阶段栅栏
Phaser.arriveAndAwaitAdvance
Phaser.arriveAndDeregister

Semaphore

控制访问公共资源的并发线程数,可以用来限流(如 数据库连接)!
Semaphore.acquire(); // 获取信号量
Semaphore.release(); // 释放信号量

Exchanger

线程间的数据交换(两个线程间)
遗传算法/校对工作
Exchanger.exchange(s); // 当前线程达到同步点(如果第二个线程未到同步点,则当前线程阻塞)

并发容器

ConcurrentHashMap

主要对 JDK7 做了三点改造:

① 取消分段锁机制,进一步降低冲突概率。

② 引入红黑树结构,同一个哈希槽上的元素个数超过一定阈值后,单向链表改为红黑树结构。

③ 使用了更加优化的方式统计集合内的元素数量。具体优化表现在:在 put、resize 和 size 方法中涉及元素总数的更新和计算都避免了锁,使用 CAS 代替。

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

当某个槽内的元素个数达到 7 且 table 容量不小于 64 时,链表转为红黑树。

当某个槽内的元素减少到 6 时,由红黑树重新转为链表。

在转化过程中,使用同步块锁住当前槽的首元素,防止其他线程对当前槽进行增删改操作,转化完成后利用 CAS 替换原有链表。由于 TreeNode 节点也存储了 next 引用,因此红黑树转为链表很简单,只需从 first 元素开始遍历所有节点,并把节点从 TreeNode 转为 Node 类型即可,当构造好新链表后同样用 CAS 替换红黑树。

ConcurrentLinkedQueue

基于链表的无界安全队列,先进先出,使用CAS保证线程安全

CopyOnWriteArrayList

读写分离。写操作会复制一个新的集合,在新集合上进行操作,完成后再将原集合引用指向这个新集合。

适合读多写少的场景。

fail-safe,在安全的副本上进行遍历,集合上的写与副本无关,这就导致可能无法读取到最新数据。

使用 Object 数组存储数据。

使用 ReentrantLock 控制访问(写操作)

阻塞队列

特性

阻塞队列支持阻塞插入和移除,当队列满时,插入元素的线程会被阻塞直到队列不满。当队列为空时,获取元素的线程会被阻塞直到队列非空。

实现原理

使用通知模式实现。

生产者往满的队列里添加元素时会阻塞,当消费者消费后,会通知生产者当前队列可用。当往队列里插入一个元素,如果队列不可用,阻塞生产者主要通过 LockSupportpark 方法实现,不同操作系统中实现方式不同,在 Linux 下使用的是系统方法 pthread_cond_wait 实现。

类型

ArrayBlockingQueue

由数组结构组成的有界阻塞队列。

默认不保证公平。

使用Object 数组存储数据

ReentrantLock + ConditionnotEmptynotFull)来控制。

LinkedBlockingQueue

由链表结构组成的有界阻塞队列。

队列的默认和最大长度为 Interger.MAX_VALUE

使用一个 Node 数据类型来存储数据(因为要存储 当前数据 和 next节点,因此不能单纯使用Object数组),记录了 链表的 headlast 来定位数据。

ReentrantLock (takeLockputLock) + Condition (notEmptynotFull) 来控制存取

PriorityBlockingQueue

支持优先级排序的无界阻塞队列。

可自定义CompareTo方法指定排序规则,但不能保证同优先级元素的顺序。

使用 Object 数据存储数据

ReentrantLock + Condition (notEmpty)控制访问

DelayQueue

支持延时获取元素的无界阻塞队列,使用优先级队列实现。

创建元素时可以指定多久才能从队列中获取当前元素,只有延迟期满才能从队列中获取元素,适用于缓存和定时调度

使用 Object 数组存储数据,元素实体要实现 Delayed 接口,通过其 getDelay方法来判断是否可以获取

ReentrantLock + Condition 控制存取

SynchronousQueue

不存储元素的阻塞队列。

每一个put必须等待一个take,默认使用非公平策略。

吞吐量高

不存储数据

内部通过 TransferQueue(公平) + TransferStack(非公平)这两个类来处理公平/非公平策略。

LinkedTransferQueue

由链表结构组成的无界阻塞队列。

LinkedBlockingDeque

由链表结构组成的双向阻塞队列。

归纳:阻塞队列中各种操作的4种处理方式

方法/处理方法抛出异常一直阻塞返回特殊值超时退出
插入方法add(e)put(e)offer(e)offer(e, time, unit)
移除方法remove(e)take()poll()poll(time, unit)
检查方法element(e)peek()
记忆方法:
put和take都有t
offer和poll都有o

锁的优化

锁消除

  • 逃逸分析:判断一个方法中的变量,能否被方法外中访问。

    通过逃逸分析,可以判断是否存在线程安全问题,并进行相关的优化。
    举例:

    仅仅只在方法内部使用的StringBuffer,可以被优化为StringBuilder

锁粗化

举例:
循环内部加锁,会导致重复加锁、解锁,可以优化为循环外加锁、解锁一次

锁分离

将锁进行拆分。

  • 将大锁拆分为小锁。如 ConcurrentHashMap、LongAdder
  • 将锁按读写功能拆分。如 ReadWriteReentrantLock

    详见笔者另一篇文章:

锁降级

写锁降级为读锁:持有写锁,再获取到读锁(为了保证数据的可见性),随后释放原来的写锁