一、多线程之锁优化

1.1在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生
stop the word 后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM
参数关闭偏向锁来调优系统性能,示例代码如下:
 

-XX:-UseBiasedLocking // 关闭偏向锁(默认打开)
-XX:+UseHeavyMonitors // 设置重量级锁

在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈
或锁占用的时间过长,自旋锁将会导致大量的线程一直处于 CAS 重试状态,占用 CPU 资
源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。
在高负载、高并发的场景下,我们可以通过设置 JVM 参数来关闭自旋锁,优化系统性能,
示例代码如下
 

-XX:-UseSpinning // 参数关闭自旋锁优化 (默认打开)
-XX:PreBlockSpin // 参数修改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 控制

1.2减小锁粒度

除了锁内部优化和编译器优化之外,我们还可以通过代码层来实现锁优化,减小锁粒度就是
一种惯用的方法。
当我们的锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重
量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行
度。
最经典的减小锁粒度的案例就是 JDK1.8 之前实现的 ConcurrentHashMap 版本。我们知
道,HashTable 是基于一个数组 + 链表实现的,所以在并发读写操作集合时,存在激烈的
锁资源竞争,也因此性能会存在瓶颈。而 ConcurrentHashMap 就很很巧妙地使用了分段
锁 Segment 来降低锁资源竞争,如下图所示:

java 控制线程内存大小 java线程调优_java

总结
JVM 在 JDK1.6 中引入了分级锁机制来优化 Synchronized,当一个线程获取锁时,首先对
象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换
问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有
锁,且分锁有交替切换的场景;偏向锁还使用了自旋锁来避免线程用户态与内核态的频繁切
换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。
减少锁竞争,是优化 Synchronized 同步锁的关键。我们应该尽量使 Synchronized 同步
锁处于轻量级锁或偏向锁,这样才能提高 Synchronized 同步锁的性能;通过减小锁粒度
来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高
Synchronized 同步锁在自旋时获取锁资源的成功率,避免 Synchronized 同步锁升级为重
量级锁。
这一讲我们重点了解了 Synchronized 同步锁优化,这里由于字数限制,也为了你能更好
地理解内容,目录中 12 讲的内容我拆成了两讲,在下一讲中,我会重点讲解 Lock 同步锁
的优化方法。
1.3 深入了解Lock同步锁的优化方法

相对于需要 JVM 隐式获取和释放锁的 Synchronized 同步

锁,Lock 同步锁(以下简称 Lock 锁)需要的是显示获取和

释放锁,这就为获取和释放锁提供了更多的灵活性。Lock

锁的基本操作是通过乐观锁来实现的,但由于 Lock 锁也会

在阻塞时被挂起,因此它依然属于悲观锁。我们可以通过一

张图来简单对比下两个同步锁,了解下各自的特点

java 控制线程内存大小 java线程调优_java_02

从性能方面上来说,在并发量不高、竞争不激烈的情况下,
Synchronized 同步锁由于具有分级锁的优势,性能上与
Lock 锁差不多;但在高负载、高并发的情况下,
Synchronized 同步锁由于竞争激烈会升级到重量级锁,性
能则没有 Lock 锁稳定。
 

Lock 锁是基于 Java 实现的锁,Lock 是一个接口类,常用

的实现类有 ReentrantLock、

ReentrantReadWriteLock(RRW),它们都是依赖

AbstractQueuedSynchronizer(AQS)类实现的。

AQS 类结构中包含一个基于链表实现的等待队列(CLH 队

列),用于存储所有阻塞的线程,AQS 中还有一个 state

变量,该变量对 ReentrantLock 来说表示加锁状态。

该队列的操作均通过 CAS 操作实现,我们可以通过一张图

来看下整个获取锁的流程

java 控制线程内存大小 java线程调优_乐观锁_03

1.4 锁分离优化 Lock 同步锁

虽然 Lock 锁的性能稳定,但也并不是所有的场景下都默认

使用 ReentrantLock 独占锁来实现线程同步。

我们知道,对于同一份数据进行读写,如果一个线程在读数

据,而另一个线程在写数据,那么读到的数据和最终的数据

就会不一致;如果一个线程在写数据,而另一个线程也在写

数据,那么线程前后看到的数据也会不一致。这个时候我们

可以在读写方法中加入互斥锁,来保证任何时候只能有一个

线程进行读或写操作。

在大部分业务场景中,读业务操作要远远大于写业务操作。

而在多线程编程中,读操作并不会修改共享资源的数据,如

果多个线程仅仅是读取共享资源,那么这种情况下其实没有

必要对资源进行加锁。如果使用互斥锁,反倒会影响业务的

并发性能,那么在这种场景下,有没有什么办法可以优化下

锁的实现方式呢?

读写锁 ReentrantReadWriteLock

针对这种读多写少的场景,Java 提供了另外一个实现 Lock

接口的读写锁 RRW。我们已知 ReentrantLock 是一个独占

锁,同一时间只允许一个线程访问,而 RRW 允许多个读线

程同时访问,但不允许写线程和读线程、写线程和写线程同

时访问。读写锁内部维护了两个锁,一个是用于读操作的

ReadLock,一个是用于写操作的 WriteLock。

那读写锁又是如何实现锁分离来保证共享资源的原子性呢?

RRW 也是基于 AQS 实现的,它的自定义同步器(继承

AQS)需要在同步状态 state 上维护多个读线程和一个写线

程的状态,该状态的设计成为实现读写锁的关键。RRW 很

好地使用了高低位,来实现一个整型控制两种状态的功能,

读写锁将变量切分成了两个部分,高 16 位表示读,低 16

位表示写。

一个线程尝试获取写锁时,会先判断同步状态 state 是否为

0。如果 state 等于 0,说明暂时没有其它线程获取锁;如

果 state 不等于 0,则说明有其它线程获取了锁。

此时再判断同步状态 state 的低 16 位(w)是否为 0,如

果 w 为 0,则说明其它线程获取了读锁,此时进入 CLH 队

列进行阻塞等待;如果 w 不为 0,则说明其它线程获取了写

锁,此时要判断获取了写锁的是不是当前线程,若不是就进

入 CLH 队列进行阻塞等待;若是,就应该判断当前线程获

取写锁是否超过了最大次数,若超过,抛异常,反之更新同

步状态。

java 控制线程内存大小 java线程调优_乐观锁_04

一个线程尝试获取读锁时,同样会先判断同步状态 state 是
否为 0。如果 state 等于 0,说明暂时没有其它线程获取
锁,此时判断是否需要阻塞,如果需要阻塞,则进入 CLH
队列进行阻塞等待;如果不需要阻塞,则 CAS 更新同步状
态为读状态。
如果 state 不等于 0,会判断同步状态低 16 位,如果存在
写锁,则获取读锁失败,进入 CLH 阻塞队列;反之,判断
当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同
步状态,获取成功更新同步锁为读状态。
 

java 控制线程内存大小 java线程调优_同步锁_05

下面我们通过一个求平方的例子,来感受下 RRW 的实现,
代码如下:
 

public class TestRTTLock {
private double x, y;
private ReentrantReadWriteLock lock = new Reent
// 读锁
private Lock readLock = lock.readLock();
// 写锁
private Lock writeLock = lock.writeLock();
public double read() {
// 获取读锁
readLock.lock();
try {
return Math.sqrt(x * x + y * y)
} finally {
// 释放读锁
readLock.unlock();
}
}
public void move(double deltaX, double deltaY)
// 获取写锁
writeLock.lock();
try {
x += deltaX;
y += deltaY;
} finally {
// 释放写锁
writeLock.unlock();
}
}
}

1.5读写锁再优化之 StampedLock

RRW 被很好地应用在了读大于写的并发场景中,然而 RRW
在性能上还有可提升的空间。在读取很多、写入很少的情况
下,RRW 会使写入线程遭遇饥饿(Starvation)问题,也
就是说写入线程会因迟迟无法竞争到锁而一直处于等待状
态。
在 JDK1.8 中,Java 提供了 StampedLock 类解决了这个问
题。StampedLock 不是基于 AQS 实现的,但实现的原理
和 AQS 是一样的,都是基于队列和锁状态实现的。与
RRW 不一样的是,StampedLock 控制锁有三种模式: 写、
悲观读以及乐观读,并且 StampedLock 在获取锁时会返回
一个票据 stamp,获取的 stamp 除了在释放锁时需要校
验,在乐观读模式下,stamp 还会作为读取共享资源后的二
次校验,后面我会讲解 stamp 的工作原理。
我们先通过一个官方的例子来了解下 StampedLock 是如何
使用的,代码如下:
 

public class Point {
private double x, y;
private final StampedLock s1 = new StampedLock();
void move(double deltaX, double deltaY) {
// 获取写锁
long stamp = s1.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
// 释放写锁
s1.unlockWrite(stamp);
}
}
double distanceFormOrigin() {
// 乐观读操作
long stamp = s1.tryOptimisticRead();
// 拷贝变量
double currentX = x, currentY = y;
// 判断读期间是否有写操作
if (!s1.validate(stamp)) {
// 升级为悲观读
stamp = s1.readLock();
try {
currentX = x;
currentY = y;
} finally {
s1.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY
}
}

我们可以发现:一个写线程获取写锁的过程中,首先是通过
WriteLock 获取一个票据 stamp,WriteLock 是一个独占
锁,同时只有一个线程可以获取该锁,当一个线程获取该锁
后,其它请求的线程必须等待,当没有线程持有读锁或者写
锁的时候才可以获取到该锁。请求该锁成功后会返回一个
stamp 票据变量,用来表示该锁的版本,当释放该锁的时
候,需要 unlockWrite 并传递参数 stamp
接下来就是一个读线程获取锁的过程。首先线程会通过乐观
锁 tryOptimisticRead 操作获取票据 stamp ,如果当前没
有线程持有写锁,则返回一个非 0 的 stamp 版本信息。线
程获取该 stamp 后,将会拷贝一份共享资源到方法栈,在
这之前具体的操作都是基于方法栈的拷贝数据。
之后方法还需要调用 validate,验证之前调用
tryOptimisticRead 返回的 stamp 在当前是否有其它线程
持有了写锁,如果是,那么 validate 会返回 0,升级为悲观
锁;否则就可以使用该 stamp 版本的锁对数据进行操作。
相比于 RRW,StampedLock 获取读锁只是使用与或操作
进行检验,不涉及 CAS 操作,即使第一次乐观锁获取失
35 }
败,也会马上升级至悲观锁,这样就可以避免一直进行 CAS
操作带来的 CPU 占用性能的问题,因此 StampedLock 的
效率更高
总结
不管使用 Synchronized 同步锁还是 Lock 同步锁,只要存
在锁竞争就会产生线程阻塞,从而导致线程之间的频繁切
换,最终增加性能消耗。因此,如何降低锁竞争,就成为了
优化锁的关键。
在 Synchronized 同步锁中,我们了解了可以通过减小锁粒
度、减少锁占用时间来降低锁的竞争。在这一讲中,我们知
道可以利用 Lock 锁的灵活性,通过锁分离的方式来降低锁
竞争。
Lock 锁实现了读写锁分离来优化读大于写的场景,从普通
的 RRW 实现到读锁和写锁,到 StampedLock 实现了乐观
读锁、悲观读锁和写锁,都是为了降低锁的竞争,促使系统
的并发性能达到最佳。
 

1.6 使用乐观锁优化并行操作

前两讲我们讨论了 Synchronized 和 Lock 实现的同步锁机
制,这两种同步锁都属于悲观锁,是保护线程安全最直观的
方式。
我们知道悲观锁在高并发的场景下,激烈的锁竞争会造成线
程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统
的性能开销。那有没有可能实现一种非阻塞型的锁机制来保
证线程的安全呢?答案是肯定的。今天我就带你学习下乐观
锁的优化方法,看看怎么使用才能发挥它最大的价值
什么是乐观锁
开始优化前,我们先来简单回顾下乐观锁的定义。
乐观锁,顾名思义,就是说在操作共享资源时,它总是抱着
乐观的态度进行,它认为自己可以成功地完成操作。但实际
上,当多个线程同时操作一个共享资源时,只有一个线程会
成功,那么失败的线程呢?它们不会像悲观锁一样在操作系
统中挂起,而仅仅是返回,并且系统允许失败的线程重试,
也允许自动放弃退出操作。
所以,乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性
故障问题,线程间的相互影响也远远比悲观锁要小。更为重
要的是,乐观锁没有因竞争造成的系统开销,所以在性能上
也是更胜一筹
乐观锁的实现原理
相信你对上面的内容是有一定的了解的,下面我们来看看乐
观锁的实现原理,有助于我们从根本上总结优化方法。
CAS 是实现乐观锁的核心算法,它包含了 3 个参数:V(需
要更新的变量)、E(预期值)和 N(最新值)。
只有当需要更新的变量等于预期值时,需要更新的变量才会
被设置为最新值,如果更新值和预期值不同,则说明已经有
其它线程更新了需要更新的变量,此时当前线程不做操作,
返回 V 的真实值。
1.7 CAS 如何实现原子操作
在 JDK 中的 concurrent 包中,atomic 路径下的类都是基
于 CAS 实现的。AtomicInteger 就是基于 CAS 实现的一个
线程安全的整型类。下面我们通过源码来了解下如何使用
CAS 实现原子操作。
我们可以看到 AtomicInteger 的自增方法
getAndIncrement 是用了 Unsafe 的 getAndAddInt 方
法,显然 AtomicInteger 依赖于本地方法 Unsafe 类,
Unsafe 类中的操作方法会调用 CPU 底层指令实现原子操

// 基于 CAS 操作更新值
public final boolean compareAndSet(int expect, int
return unsafe.compareAndSwapInt(this, valueOffs
}
// 基于 CAS 操作增 1
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1
}
// 基于 CAS 操作减 1
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset,

处理器如何实现原子操作
CAS 是调用处理器底层指令来实现原子操作,那么处理器底
层又是如何实现原子操作的呢?
处理器和物理内存之间的通信速度要远慢于处理器间的处理
速度,所以处理器有自己的内部缓存。如下图所示,在执行
操作时,频繁使用的内存数据会缓存在处理器的 L1、L2 和
L3 高速缓存中,以加快频繁读取的速度。
一般情况下,一个单核处理器能自我保证基本的内存操作是
原子性的,当一个线程读取一个字节时,所有进程和线程看
到的字节都是同一个缓存里的字节,其它线程不能访问这个
字节的内存地址。
但现在的服务器通常是多处理器,并且每个处理器都是多核
的。每个处理器维护了一块字节的内存,每个内核维护了一
块字节的缓存,这时候多线程并发就会存在缓存不一致的问
题,从而导致数据不一致。
这个时候,处理器提供了总线锁定和缓存锁定两个机制来保
证复杂内存操作的原子性。
当处理器要操作一个共享变量的时候,其在总线上会发出一
个 Lock 信号,这时其它处理器就不能操作共享变量了,该
处理器会独享此共享内存中的变量。但总线锁定在阻塞其它
处理器获取该共享变量的操作请求时,也可能会导致大量阻
塞,从而增加系统的性能开销。
于是,后来的处理器都提供了缓存锁定机制,也就说当某个
处理器对缓存中的共享变量进行了操作,就会通知其它处理
器放弃存储该共享资源或者重新读取该共享资源。目前最新
的处理器都支持缓存锁定机制。
优化 CAS 乐观锁
虽然乐观锁在并发性能上要比悲观锁优越,但是在写大于读
的操作场景下,CAS 失败的可能性会增大,如果不放弃此次
CAS 操作,就需要循环做 CAS 重试,这无疑会长时间地占
用 CPU。
在 Java7 中,通过以下代码我们可以看到:AtomicInteger
的 getAndSet 方法中使用了 for 循环不断重试 CAS 操作,
如果长时间不成功,就会给 CPU 带来非常大的执行开销。
到了 Java8,for 循环虽然被去掉了,但我们反编译 Unsafe
类时就可以发现该循环其实是被封装在了 Unsafe 类中,
CPU 的执行开销依然存在。

public final int getAndSet(int newValue) {
for (;;) {
int current = get();
if (compareAndSet(current, newValue))
return current;
}
}

在 JDK1.8 中,Java 提供了一个新的原子类 LongAdder。
LongAdder 在高并发场景下会比 AtomicInteger 和
AtomicLong 的性能更好,代价就是会消耗更多的内存空
间。
LongAdder 的原理就是降低操作共享变量的并发数,也就
是将对单一共享变量的操作压力分散到多个变量值上,将竞
争的每个写线程的 value 值分散到一个数组中,不同线程会
命中到数组的不同槽中,各个线程只对自己槽中的 value 值
进行 CAS 操作,最后在读取值的时候会将原子操作的共享
变量与各个分散在数组的 value 值相加,返回一个近似准确
的数值。
LongAdder 内部由一个 base 变量和一个 cell[] 数组组
成。当只有一个写线程,没有竞争的情况下,LongAdder
会直接使用 base 变量作为原子操作变量,通过 CAS 操作

public final int getAndSet(int newValue) {
for (;;) {
int current = get();
if (compareAndSet(current, newValue))
return current;
}
}

修改变量;当有多个写线程竞争的情况下,除了占用 base

变量的一个写线程之外,其它各个线程会将修改的变量写入

到自己的槽 cell[] 数组中,最终结果可通过以下公式计算得

出:

java 控制线程内存大小 java线程调优_读锁_06

我们可以发现,LongAdder 在操作后的返回值只是一个近
似准确的数值,但是 AtomicLong最终返回的是一个准确的
数值, 所以在一些对实时性要求比较高的场景下,
LongAdder 并不能取代 AtomicInteger 或 AtomicLong。

总结

在日常开发中,使用乐观锁最常见的场景就是数据库的更新

操作了。为了保证操作数据库的原子性,我们常常会为每一

条数据定义一个版本号,并在更新前获取到它,到了更新数

据库的时候,还要判断下已经获取的版本号是否被更新过,

如果没有,则执行该操作。

CAS 乐观锁在平常使用时比较受限,它只能保证单个变量操

作的原子性,当涉及到多个变量时,CAS 就无能为力了,但

前两讲讲到的悲观锁可以通过对整个代码块加锁来做到这

点。

CAS 乐观锁在高并发写大于读的场景下,大部分线程的原子

操作会失败,失败后的线程将会不断重试 CAS 原子操作,

这样就会导致大量线程长时间地占用 CPU 资源,给系统带

来很大的性能开销。在 JDK1.8 中,Java 新增了一个原子类

LongAdder,它使用了空间换时间的方法,解决了上述问

题。

鉴于脱离实际业务场景的性能对比测试没有意义,我们可以

分别在“读多写少”“读少写多”“读写差不多”这三种场

景下进行测试。又因为锁的性能还与竞争的激烈程度有关,

所以除此之外,我们还将做三种锁在不同竞争级别下的性能

测试。

综合上述条件,我将对四种模式下的五个锁

Synchronized、ReentrantLock、

ReentrantReadWriteLock、StampedLock 以及乐观锁

LongAdder 进行压测。

java 控制线程内存大小 java线程调优_同步锁_07

通过以上结果,我们可以发现:在读大于写的场景下,读写
锁 ReentrantReadWriteLock、StampedLock 以及乐观锁
的读写性能是最好的;在写大于读的场景下,乐观锁的性能
是最好的,其它 4 种锁的性能则相差不多;在读和写差不多
的场景下,两种读写锁以及乐观锁的性能要优于
Synchronized 和 ReentrantLock。
 

1.8 哪些操作导致了上下文切换?

在并发程序中,并不是启动更
多的线程就能让程序最大限度地并发执行。线程数量设置太
小,会导致程序不能充分地利用系统资源;线程数量设置太
大,又可能带来资源的过度竞争,导致上下文切换带来额外
的系统开销。
其实在单个处理器的时期,操作系统就能处理多线程并发任
务。处理器给每个线程分配 CPU 时间片(Time Slice),
线程在分配获得的时间片内执行任务。
CPU 时间片是 CPU 分配给每个线程执行的时间段,一般为
几十毫秒。在这么短的时间内线程互相切换,我们根本感觉
不到,所以看上去就好像是同时进行的一样。
时间片决定了一个线程可以连续占用处理器运行的时长。当
一个线程的时间片用完了,或者因自身原因被迫暂停运行
了,这个时候,另外一个线程(可以是同一个线程或者其它
进程的线程)就会被操作系统选中,来占用处理器。这种一
个线程被暂停剥夺使用权,另外一个线程被选中开始或者继
续运行的过程就叫做上下文切换(Context Switch)。
具体来说,一个线程被剥夺处理器的使用权而被暂停运行,
就是“切出”;一个线程被选中占用处理器开始或者继续运
行,就是“切入”。在这种切出切入的过程中,操作系统需
要保存和恢复相应的进度信息,这个进度信息就是“上下
文”了。
那上下文都包括哪些内容呢?具体来说,它包括了寄存器的
存储内容以及程序计数器存储的指令内容。CPU 寄存器负
责存储已经、正在和将要执行的任务,程序计数器负责存储
CPU 正在执行的指令位置以及即将执行的下一条指令的位
置。
在当前 CPU 数量远远不止一个的情况下,操作系统将 CPU
轮流分配给线程任务,此时的上下文切换就变得更加频繁
了,并且存在跨 CPU 上下文切换,比起单核上下文切换,
跨核切换更加昂贵。

通过线程的运行状态以及状态间的相互切换,我们可以了解
到,多线程的上下文切换实际上就是由多线程两个运行状态
的互相切换导致的。
那么在线程运行时,线程状态由 RUNNING 转为
BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这又是什
么诱发的呢?

一种是程序本身触发的切换,
这种我们称为自发性上下文切换,另一种是由系统或者虚拟
机诱发的非自发性上下文切换。

自发性上下文切换指线程由 Java 程序调用导致切出,在多
线程编程中,执行调用以下方法或关键字,常常就会引发自
发性上下文切换。
sleep()
wait()
yield()
join()
park()
synchronized
lock
非自发性上下文切换指线程由于调度器的原因被迫切出。常
见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或
者执行优先级的问题导致

虚拟机垃圾回收为什么会导致上下文切
换”。在 Java 虚拟机中,对象的内存都是由虚拟机中的堆
分配的,在程序运行过程中,新的对象将不断被创建,如果
旧的对象使用后不进行回收,堆内存将很快被耗尽。Java
虚拟机提供了一种回收机制,对创建后不再使用的对象进行
回收,从而保证堆内存的可持续性分配。而这种垃圾回收机
制的使用有可能会导致 stop-the-world 事件的发生,这其
实就是一种线程暂停行为。
发现上下文切换
我们总说上下文切换会带来系统开销,那它带来的性能问题
是不是真有这么糟糕呢?我们又该怎么去监测到上下文切
换?上下文切换到底开销在哪些环节?接下来我将给出一段
代码,来对比串联执行和并发执行的速度,然后一一解答这
些问题。

串联的执行速度比并发的执行
速度要快。这就是因为线程的上下文切换导致了额外的开
销,使用 Synchronized 锁关键字,导致了资源竞争,从而
引起了上下文切换,但即使不使用 Synchronized 锁关键
字,并发的执行速度也无法超越串联的执行速度,这是因为
多线程同样存在着上下文切换。Redis、NodeJS 的设计就
很好地体现了单线程串行的优势。

至于系统开销具体发生在切换过程中的哪些具体环节,总结
如下:
操作系统保存和恢复上下文;
调度器进行线程调度;
处理器高速缓存重新加载;
上下文切换也可能导致整个高速缓存区被冲刷,从而带来
时间开销
总结
上下文切换就是一个工作的线程被另外一个线程暂停,另外
一个线程占用了处理器开始执行任务的过程。系统和 Java
程序自发性以及非自发性的调用操作,就会导致上下文切
换,从而带来系统开销。
线程越多,系统的运行速度不一定越快。那么我们平时在并
发量比较大的情况下,什么时候用单线程,什么时候用多线
程呢?

总结
上下文切换就是一个工作的线程被另外一个线程暂停,另外
一个线程占用了处理器开始执行任务的过程。系统和 Java
程序自发性以及非自发性的调用操作,就会导致上下文切
换,从而带来系统开销。
线程越多,系统的运行速度不一定越快。那么我们平时在并
发量比较大的情况下,什么时候用单线程,什么时候用多线
程呢?
一般在单个逻辑比较简单,而且速度相对来非常快的情况
下,我们可以使用单线程。例如,我们前面讲到的 Redis,
从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。
而在逻辑相对来说很复杂的场景,等待时间相对较长又或者
是需要大量计算的场景,我建议使用多线程来提高系统的整
体性能。例如,NIO 时期的文件读写操作、图像处理以及大
数据分析等。
1.9 如何优化多线程上下文切换?
通过上一讲的讲解,相信你对上下文切换已经有了一定的了解了。如果是单个线程,在
CPU 调用之后,那么它基本上是不会被调度出去的。如果可运行的线程数远大于 CPU 数
量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其它线程能够使用 CPU
,这就会导致上下文切换。
还有,在多线程中如果使用了竞争锁,当线程由于等待竞争锁而被阻塞时,JVM 通常会将
这个锁挂起,并允许它被交换出去。如果频繁地发生阻塞,CPU 密集型的程序就会发生更
多的上下文切换。
多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就
越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的根
源,竞争锁才是.
1.10 减少锁的持有时间
我们知道,锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。如果是
Synchronized 同步锁资源,就不仅是带来线程间的上下文切换,还有可能会增加进程间的
上下文切换。
在第 12 讲中,我曾分享过一些更具体的方法,例如,可以将一些与锁无关的代码移出同步
代码块,尤其是那些开销较大的操作以及可能被阻塞的操作
优化前

public synchronized void mySyncMethod(){
businesscode1();
mutextMethod();
businesscode2();
}

优化后

public void mySyncMethod(){
businesscode1();
synchronized(this)
{
mutextMethod();
}
businesscode2();
}

降低锁的粒度
同步锁可以保证对象的原子性,我们可以考虑将锁粒度拆分得更小一些,以此避免所有线程
对一个锁资源的竞争过于激烈。具体方式有以下两种:
锁分离
与传统锁不同的是,读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁
实现的,其规则是可以共享读,但只有一个写。
这样做的好处是,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。而
传统的独占锁在没有区分读写锁的时候,读写操作一般是:读读互斥、读写互斥、写写互
斥。所以在读远大于写的多线程场景中,锁分离避免了在高并发读情况下的资源竞争,从而
避免了上下文切换。
锁分段
我们在使用锁来保证集合或者大对象原子性时,可以考虑将锁对象进一步分解。例如,我之
前讲过的 Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段
非阻塞乐观锁替代竞争锁
volatile 关键字的作用是保障可见性及有序性,volatile 的读写操作不会导致上下文切换,
因此开销比较小。 但是,volatile 不能保证操作变量的原子性,因为没有锁的排他性。
而 CAS 是一个原子的 if-then-act 操作,CAS 是一个无锁算法实现,保障了对一个共享变
量读写操作的一致性。CAS 操作中有 3 个操作数,内存值 V、旧的预期值 A 和要修改的新
值 B,当且仅当 A 和 V 相同时,将 V 修改为 B,否则什么都不做,CAS 算法将不会导致上
下文切换。Java 的 Atomic 包就使用了 CAS 算法来更新数据,就不需要额外加锁。
上面我们了解了如何从编码层面去优化竞争锁,那么除此之外,JVM 内部其实也对
Synchronized 同步锁做了优化,我在 12 讲中有详细地讲解过,这里简单回顾一下。
在 JDK1.6 中,JVM 将 Synchronized 同步锁分为了偏向锁、轻量级锁、偏向锁以及重量
级锁,优化路径也是按照以上顺序进行。JIT 编译器在动态编译同步块的时候,也会通过锁
消除、锁粗化的方式来优化该同步锁。
优化 wait/notify 的使用,减少上下文切换
首先,我们在多个不同消费场景中,可以使用 Object.notify() 替代 Object.notifyAll()。
因为 Object.notify() 只会唤醒指定线程,不会过早地唤醒其它未满足需求的阻塞线程,所
以可以减少相应的上下文切换。
其次,在生产者执行完 Object.notify() / notifyAll() 唤醒其它线程之后,应该尽快地释放
内部锁,以避免其它线程在唤醒之后长时间地持有锁处理业务操作,这样可以避免被唤醒的
线程再次申请相应内部锁的时候等待锁的释放。
最后,为了避免长时间等待,我们常会使用 Object.wait (long)设置等待超时时间,但线
程无法区分其返回是由于等待超时还是被通知线程唤醒,从而导致线程再次尝试获取锁操
作,增加了上下文切换。
这里我建议使用 Lock 锁结合 Condition 接口替代 Synchronized 内部锁中的 wait /
notify,实现等待/通知。这样做不仅可以解决上述的 Object.wait(long) 无法区分的问
题,还可以解决线程被过早唤醒的问题。
Condition 接口定义的 await 方法 、signal 方法和 signalAll 方法分别相当于
Object.wait()、 Object.notify() 和 Object.notifyAll()

合理地设置线程池大小,避免创建过多线程
线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器
数量,就会导致过多的上下文切换。
还有一种情况就是,在有些创建线程池的方法里,线程数量设置不会直接暴露给我们。比
如,用 Executors.newCachedThreadPool() 创建的线程池,该线程池会复用其内部空闲
的线程来处理新提交的任务,如果没有,再创建新的线程(不受 MAX_VALUE 限制),这
样的线程池如果碰到大量且耗时长的任务场景,就会创建非常多的工作线程,从而导致频繁
的上下文切换。因此,这类线程池就只适合处理大量且耗时短的非阻塞任务。

使用协程实现非阻塞等待
相信很多人一听到协程(Coroutines),马上想到的就是 Go 语言。协程对于大部分 Java
程序员来说可能还有点陌生,但其在 Go 中的使用相对来说已经很成熟了。
协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则
完全由程序本身所控制,也就是在用户态执行。协程避免了像线程切换那样产生的上下文切
换,在性能方面得到了很大的提升。

减少 Java 虚拟机的垃圾回收
我们在上一讲讲上下文切换的诱因时,曾提到过“垃圾回收会导致上下文切换”。
很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎
片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。而移动内存对象就意味
着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需
要再次唤醒该线程。因此减少 JVM 垃圾回收的频率可以有效地减少上下文切换。
上下文切换是多线程编程性能消耗的原因之一,而竞争锁、线程间的通信以及过多地创建线
程等多线程编程操作,都会给系统带来上下文切换。除此之外,I/O 阻塞以及 JVM 的垃圾
回收也会增加上下文切换。
总的来说,过于频繁的上下文切换会影响系统的性能,所以我们应该避免它。另外,我们还
可以将上下文切换也作为系统的性能参考指标,并将该指标纳入到服务性能监控,防患于未

二、并发场景下的 Map 容器

2.1 我们切忌在并发场景下使用 HashMap。因为在 JDK1.7 之前,在并发场景

下使用 HashMap 会出现死循环,从而导致 CPU 使用率居高不下,而扩容是导致死循环的

主要原因。虽然 Java 在 JDK1.8 中修复了 HashMap 扩容导致的死循环问题,但在高并发

场景下,依然会有数据丢失以及不准确的情况出现。

这时为了保证容器的线程安全,Java 实现了 Hashtable、ConcurrentHashMap 以及

ConcurrentSkipListMap 等 Map 容器。

Hashtable、ConcurrentHashMap 是基于 HashMap 实现的,对于小数据量的存取比较

有优势。

ConcurrentSkipListMap 是基于 TreeMap 的设计原理实现的,略有不同的是前者基于跳

表实现,后者基于红黑树实现,ConcurrentSkipListMap 的特点是存取平均时间复杂度是

O(log(n)),适用于大数据量存取的场景,最常见的是基于跳跃表实现的数据量比较大

的缓存

回归到开始的案例再看一下,如果这个电商系统的商品总量不是特别大的话,我们可以用

Hashtable 或 ConcurrentHashMap 来实现哈希表的功能。

Hashtable 🆚 ConcurrentHashMap

更精准的话,我们可以进一步对比看看以上两种容器。

在数据不断地写入和删除,且不存在数据量累积以及数据排序的场景下,我们可以选用

Hashtable 或 ConcurrentHashMap。

Hashtable 使用 Synchronized 同步锁修饰了 put、get、remove 等方法,因此在高并发

场景下,读写操作都会存在大量锁竞争,给系统带来性能开销。

相比 Hashtable,ConcurrentHashMap 在保证线程安全的基础上兼具了更好的并发性

能。在 JDK1.7 中,ConcurrentHashMap 就使用了分段锁 Segment 减小了锁粒度,最终

优化了锁的并发操作。

到了 JDK1.8,ConcurrentHashMap 做了大量的改动,摒弃了 Segment 的概念。由于

Synchronized 锁在 Java6 之后的性能已经得到了很大的提升,所以在 JDK1.8 中,Java

重新启用了 Synchronized 同步锁,通过 Synchronized 实现 HashEntry 作为锁粒度。这

种改动将数据结构变得更加简单了,操作也更加清晰流畅。

与 JDK1.7 的 put 方法一样,JDK1.8 在添加元素时,在没有哈希冲突的情况下,会使用

CAS 进行添加元素操作;如果有冲突,则通过 Synchronized 将链表锁定,再执行接下来

的操作。

综上所述,我们在设计销量 TOP10 功能时,首选 ConcurrentHashMap。

但要注意一点,虽然 ConcurrentHashMap 的整体性能要优于 Hashtable,但在某些场景

中,ConcurrentHashMap 依然不能代替 Hashtable。例如,在强一致的场景中

ConcurrentHashMap 就不适用,原因是 ConcurrentHashMap 中的 get、size 等方法没

有用到锁,ConcurrentHashMap 是弱一致性的,因此有可能会导致某次读无法马上获取

到写入的数据

2.2 ConcurrentHashMap 🆚 ConcurrentSkipListMap

我们再看一个案例,我上家公司的操作系统中有这样一个功能,提醒用户手机卡实时流量不

足。主要的流程是服务端先通过虚拟运营商同步用户实时流量,再通过手机端定时触发查询

功能,如果流量不足,就弹出系统通知。

该功能的特点是用户量大,并发量高,写入多于查询操作。这时我们就需要设计一个缓存,

用来存放这些用户以及对应的流量键值对信息。那么假设让你来实现一个简单的缓存,你会

怎么设计呢?

你可能会考虑使用 ConcurrentHashMap 容器,但我在 07 讲中说过,该容器在数据量比

较大的时候,链表会转换为红黑树。红黑树在并发情况下,删除和插入过程中有个平衡的过

程,会牵涉到大量节点,因此竞争锁资源的代价相对比较高。

而跳跃表的操作针对局部,需要锁住的节点少,因此在并发场景下的性能会更好一些。你可

能会问了,在非线程安全的 Map 容器中,我并没有看到基于跳跃表实现的 SkipListMap

呀?这是因为在非线程安全的 Map 容器中,基于红黑树实现的 TreeMap 在单线程中的性

能表现得并不比跳跃表差。

因此就实现了在非线程安全的 Map 容器中,用 TreeMap 容器来存取大数据;在线程安全

的 Map 容器中,用 SkipListMap 容器来存取大数据。

那么 ConcurrentSkipListMap 是如何使用跳跃表来提升容器存取大数据的性能呢?我们先

来了解下跳跃表的实现原理。

什么是跳跃表

跳跃表是基于链表扩展实现的一种特殊链表,类似于树的实现,跳跃表不仅实现了横向链

表,还实现了垂直方向的分层索引。

一个跳跃表由若干层链表组成,每一层都实现了一个有序链表索引,只有最底层包含了所有

数据,每一层由下往上依次通过一个指针指向上层相同值的元素,每层数据依次减少,等到

了最顶层就只会保留部分数据了。

跳跃表的这种结构,是利用了空间换时间的方法来提高了查询效率。程序总是从最顶层开始

查询访问,通过判断元素值来缩小查询范围。我们可以通过以下几张图来了解下跳跃表的具

体实现原理。

首先是一个初始化的跳跃表

java 控制线程内存大小 java线程调优_读锁_08

当查询 key 值为 9 的节点时,此时查询路径为:

java 控制线程内存大小 java线程调优_同步锁_09

当新增一个 key 值为 8 的节点时,首先新增一个节点到最底层的链表中,根据概率算出

level 值,再根据 level 值新建索引层,最后链接索引层的新节点。新增节点和链接索引都

是基于 CAS 操作实现。

java 控制线程内存大小 java线程调优_java_10

当删除一个 key 值为 7 的结点时,首先找到待删除结点,将其 value 值设置为 null;之后

再向待删除结点的 next 位置新增一个标记结点,以便减少并发冲突;然后让待删结点的前

驱节点直接越过本身指向的待删结点,直接指向后继结点,中间要被删除的结点最终将会被

JVM 垃圾回收处理掉;最后判断此次删除后是否导致某一索引层没有其它节点了,并视情

况删除该层索引 。

java 控制线程内存大小 java线程调优_乐观锁_11

通过以上两个案例,我想你应该清楚了 Hashtable、ConcurrentHashMap 以及

ConcurrentSkipListMap 这三种容器的适用场景了。

如果对数据有强一致要求,则需使用 Hashtable;在大部分场景通常都是弱一致性的情况

下,使用 ConcurrentHashMap 即可;如果数据量在千万级别,且存在大量增删改操作,

则可以考虑使用 ConcurrentSkipListMap。

java 控制线程内存大小 java线程调优_同步锁_12

2.3 Java 提供了线程池概念,对于频繁创建线程的业务场景,线程池

可以创建固定的线程数量,并且在操作系统底层,轻量级进程将会把这些线程映射到内核。

线程池可以提高线程复用,又可以固定最大线程使用量,防止无限制地创建线程。当程序提

交一个任务需要一个线程时,会去线程池中查找是否有空闲的线程,若有,则直接使用线程

池中的线程工作,若没有,会去判断当前已创建的线程数量是否超过最大线程数量,如未超

过,则创建新线程,如已超过,则进行排队等待或者直接抛出异常。

线程池框架 Executor

Java 最开始提供了 ThreadPool 实现了线程池,为了更好地实现用户级的线程调度,更有

效地帮助开发人员进行多线程开发,Java 提供了一套 Executor 框架。

这个框架中包括了 ScheduledThreadPoolExecutor 和 ThreadPoolExecutor 两个核心线

程池。前者是用来定时执行任务,后者是用来执行被提交的任务。鉴于这两个线程池的核心

原理是一样的,下面我们就重点看看 ThreadPoolExecutor 类是如何实现线程池的。

Executors 实现了以下四种类型的 ThreadPoolExecutor:

java 控制线程内存大小 java线程调优_乐观锁_13

Executors 利用工厂模式实现的四种线程池,我们在使用的时候需要结合生产环境下的实际
场景。不过我不太推荐使用它们,因为选择使用 Executors 提供的工厂类,将会忽略很多
线程池的参数设置,工厂类一旦选择设置默认参数,就很容易导致无法调优参数设置,从而
产生性能问题或者资源浪费
这里我建议你使用 ThreadPoolExecutor 自我定制一套线程池。进入四种工厂类后,我们
可以发现除了 newScheduledThreadPool 类,其它类均使用了 ThreadPoolExecutor 类
进行实现,你可以通过以下代码简单看下该方法:
 

public ThreadPoolExecutor(int corePoolSize,// 线程池的核心线程数量
int maximumPoolSize,// 线程池的最大线程数
long keepAliveTime,// 当线程数大于核心线程数时,多余的空闲线程
TimeUnit unit,// 时间单位
BlockingQueue<Runnable> workQueue,// 任务队列,用来储存等待执
ThreadFactory threadFactory,// 线程工厂,用来创建线程,一般默
RejectedExecutionHandler handler) // 拒绝策略,当提交的任务

java 控制线程内存大小 java线程调优_java 控制线程内存大小_14

通过上图,我们发现线程池有两个线程数的设置,一个为核心线程数,一个为最大线程数。

在创建完线程池之后,默认情况下,线程池中并没有任何线程,等到有任务来才创建线程去

执行任务。

但有一种情况排除在外,就是调用 prestartAllCoreThreads() 或者 prestartCoreThread()

方法的话,可以提前创建等于核心线程数的线程数量,这种方式被称为预热,在抢购系统中

就经常被用到。

当创建的线程数等于 corePoolSize 时,提交的任务会被加入到设置的阻塞队列中。当队列

满了,会创建线程执行任务,直到线程池中的数量等于 maximumPoolSize。

当线程数量已经等于 maximumPoolSize 时, 新提交的任务无法加入到等待队列,也无法

创建非核心线程直接执行,我们又没有为线程池设置拒绝策略,这时线程池就会抛出

RejectedExecutionException 异常,即线程池拒绝接受这个任务。

当线程池中创建的线程数量超过设置的 corePoolSize,在某些线程处理完任务后,如果等

待 keepAliveTime 时间后仍然没有新的任务分配给它,那么这个线程将会被回收。线程池

回收线程时,会对所谓的“核心线程”和“非核心线程”一视同仁,直到线程池中线程的数

量等于设置的 corePoolSize 参数,回收过程才会停止。

即使是 corePoolSize 线程,在一些非核心业务的线程池中,如果长时间地占用线程数量,

也可能会影响到核心业务的线程池,这个时候就需要把没有分配任务的线程回收掉。

我们可以通过 allowCoreThreadTimeOut 设置项要求线程池:将包括“核心线程”在内

的,没有任务分配的所有线程,在等待 keepAliveTime 时间后全部回收掉。

我们可以通过下面这张图来了解下线程池的线程分配流程:

java 控制线程内存大小 java线程调优_读锁_15

计算线程数量
了解完线程池的实现原理和框架,我们就可以动手实践优化线程池的设置了。
我们知道,环境具有多变性,设置一个绝对精准的线程数其实是不大可能的,但我们可以
通过一些实际操作因素来计算出一个合理的线程数,避免由于线程池设置不合理而导致的性
能问题。下面我们就来看看具体的计算方法。
一般多线程执行的任务类型可以分为 CPU 密集型和 I/O 密集型,根据不同的任务类型,我
们计算线程数的方法也不一样
CPU 密集型任务:这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心
数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因
导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下
多出来的一个线程就可以充分利用 CPU 的空闲时间。
下面我们用一个例子来验证下这个方法的可行性,通过观察 CPU 密集型任务在不同线程数
下的性能情况就可以得出结果,你可以点击Github下载到本地运行测试:
当线程数量太小,同一时间大量请求将被阻塞在线程队列中排队等待执行线程,
此时 CPU 没有得到充分利用;当线程数量太大,被创建的执行线程同时在争取 CPU 资
源,又会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。通过测
试可知,4~6 个线程数是最合适的
I/O 密集型任务:这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在
处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因
此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N
那么碰上一些常规的业务操作,比如,通过一个线程池实现向用户定时
推送消息的业务,我们又该如何设置线程池的数量呢?
此时我们可以参考以下公式来计算线程数
线程数 =N(CPU 核数)*(1+WT(线程等待时间)/ST(线程时间运行时间))
我们可以根据自己的业务场景,从“N+1”和“2N”两个公式中选出一个适合
的,计算出一个大概的线程数量,之后通过实际压测,逐渐往“增大线程数量”和“减小线
程数量”这两个方向调整,然后观察整体的处理时间变化,最终确定一个具体的线程数量。
总结
今天我们主要学习了线程池的实现原理,Java 线程的创建和消耗会给系统带来性能开销,
因此 Java 提供了线程池来复用线程,提高程序的并发效率。
Java 通过用户线程与内核线程结合的 1:1 线程模型来实现,Java 将线程的调度和管理设置
在了用户态,提供了一套 Executor 框架来帮助开发人员提高效率。Executor 框架不仅包
括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,可以说 Executor 框架为并
发编程提供了一个完善的架构体系。
在不同的业务场景以及不同配置的部署机器中,线程池的线程数量设置是不一样的。其设置
不宜过大,也不宜过小,要根据具体情况,计算出一个大概的数值,再通过实际的性能测
试,计算出一个合理的线程数量。
我们要提高线程池的处理能力,一定要先保证一个合理的线程数量,也就是保证 CPU 处理
线程的最大化。在此前提下,我们再增大线程池队列,通过队列将来不及处理的线程缓存起
来。在设置缓存队列时,我们要尽量使用一个有界队列,以防因队列过大而导致的内存溢出
问题。
2.4 Kilim 协程框架
在有严重阻塞的场景
下,协程的性能更胜一筹。其实,I/O 阻塞型场景也就是协
程在 Java 中的主要应用
协程和线程密切相关,协程可以认为是运行在线程上的代码
块,协程提供的挂起操作会使协程暂停执行,而不会导致线
程阻塞。
协程又是一种轻量级资源,即使创建了上千个协程,对于系
统来说也不是很大的负担,但如果在程序中创建上千个线
程,那系统可真就压力山大了。可以说,协程的设计方式极
大地提高了线程的使用率。
通过今天的学习,当其他人侃侃而谈 Go 语言在网络编程中
的优势时,相信你不会一头雾水。学习 Java 的我们也不要
觉得,协程离我们很遥远了。协程是一种设计思想,不仅仅
局限于某一门语言,况且 Java 已经可以借助协程框架实现
协程了。
但话说回来,协程还是在 Go 语言中的应用较为成熟,在
Java 中的协程目前还不是很稳定,重点是缺乏大型项目的
验证,可以说 Java 的协程设计还有很长的路要走。