mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
del
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<audio id="audio" title="12 | 多线程之锁优化(上):深入了解Synchronized同步锁的优化方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/c3/b65d73406af694be02901d943d1c52c3.mp3"></audio>
|
||||
|
||||
你好,我是刘超。从这讲开始,我们就正式进入到第三模块——多线程性能调优。
|
||||
|
||||
**在并发编程中,多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。**在JDK1.5之前,Java是依靠Synchronized关键字实现锁功能来做到这点的。Synchronized是JVM实现的一种内置锁,锁的获取和释放是由JVM隐式实现。
|
||||
|
||||
到了JDK1.5版本,并发包中新增了Lock接口来实现锁功能,它提供了与Synchronized关键字类似的同步功能,只是在使用时需要显式获取和释放锁。
|
||||
|
||||
Lock同步锁是基于Java实现的,而Synchronized是基于底层操作系统的Mutex Lock实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized同步锁在性能上就表现得非常糟糕,它也常被大家称为重量级锁。
|
||||
|
||||
特别是在单个线程重复申请锁的情况下,JDK1.5版本的Synchronized锁性能要比Lock的性能差很多。例如,在Dubbo基于Netty实现的通信中,消费端向服务端通信之后,由于接收返回消息是异步,所以需要一个线程轮询监听返回信息。而在接收消息时,就需要用到锁来确保request session的原子性。如果我们这里使用Synchronized同步锁,那么每当同一个线程请求锁资源时,都会发生一次用户态和内核态的切换。
|
||||
|
||||
到了JDK1.6版本之后,Java对Synchronized同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了Lock同步锁。这一讲我们就来看看Synchronized同步锁究竟是通过了哪些优化,实现了性能地提升。
|
||||
|
||||
## Synchronized同步锁实现原理
|
||||
|
||||
了解Synchronized同步锁优化之前,我们先来看看它的底层实现原理,这样可以帮助我们更好地理解后面的内容。
|
||||
|
||||
**通常Synchronized实现同步锁的方式有两种,一种是修饰方法,一种是修饰方法块。**以下就是通过Synchronized实现的两种同步方法加锁的方式:
|
||||
|
||||
```
|
||||
// 关键字在实例方法上,锁为当前实例
|
||||
public synchronized void method1() {
|
||||
// code
|
||||
}
|
||||
|
||||
// 关键字在代码块上,锁为括号里面的对象
|
||||
public void method2() {
|
||||
Object o = new Object();
|
||||
synchronized (o) {
|
||||
// code
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我们可以通过反编译看下具体字节码的实现,运行以下反编译命令,就可以输出我们想要的字节码:
|
||||
|
||||
```
|
||||
javac -encoding UTF-8 SyncTest.java //先运行编译class文件命令
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
javap -v SyncTest.class //再通过javap打印出字节文件
|
||||
|
||||
```
|
||||
|
||||
通过输出的字节码,你会发现:Synchronized在修饰同步代码块时,是由 monitorenter和monitorexit指令来实现同步的。进入monitorenter 指令后,线程将持有Monitor对象,退出monitorenter指令后,线程将释放该Monitor对象。
|
||||
|
||||
```
|
||||
public void method2();
|
||||
descriptor: ()V
|
||||
flags: ACC_PUBLIC
|
||||
Code:
|
||||
stack=2, locals=4, args_size=1
|
||||
0: new #2
|
||||
3: dup
|
||||
4: invokespecial #1
|
||||
7: astore_1
|
||||
8: aload_1
|
||||
9: dup
|
||||
10: astore_2
|
||||
11: monitorenter //monitorenter 指令
|
||||
12: aload_2
|
||||
13: monitorexit //monitorexit 指令
|
||||
14: goto 22
|
||||
17: astore_3
|
||||
18: aload_2
|
||||
19: monitorexit
|
||||
20: aload_3
|
||||
21: athrow
|
||||
22: return
|
||||
Exception table:
|
||||
from to target type
|
||||
12 14 17 any
|
||||
17 20 17 any
|
||||
LineNumberTable:
|
||||
line 18: 0
|
||||
line 19: 8
|
||||
line 21: 12
|
||||
line 22: 22
|
||||
StackMapTable: number_of_entries = 2
|
||||
frame_type = 255 /* full_frame */
|
||||
offset_delta = 17
|
||||
locals = [ class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object ]
|
||||
stack = [ class java/lang/Throwable ]
|
||||
frame_type = 250 /* chop */
|
||||
offset_delta = 4
|
||||
|
||||
```
|
||||
|
||||
再来看以下同步方法的字节码,你会发现:当Synchronized修饰同步方法时,并没有发现monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。
|
||||
|
||||
这是因为JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED访问标志。如果设置了该标志,执行线程将先持有Monitor对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该Mointor对象,当方法执行完成后,再释放该Monitor对象。
|
||||
|
||||
```
|
||||
public synchronized void method1();
|
||||
descriptor: ()V
|
||||
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志
|
||||
Code:
|
||||
stack=0, locals=1, args_size=1
|
||||
0: return
|
||||
LineNumberTable:
|
||||
line 8: 0
|
||||
|
||||
|
||||
```
|
||||
|
||||
通过以上的源码,我们再来看看Synchronized修饰方法是怎么实现锁原理的。
|
||||
|
||||
JVM中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。Monitor是由ObjectMonitor实现,而ObjectMonitor是由C++的ObjectMonitor.hpp文件实现,如下所示:
|
||||
|
||||
```
|
||||
ObjectMonitor() {
|
||||
_header = NULL;
|
||||
_count = 0; //记录个数
|
||||
_waiters = 0,
|
||||
_recursions = 0;
|
||||
_object = NULL;
|
||||
_owner = NULL;
|
||||
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
|
||||
_WaitSetLock = 0 ;
|
||||
_Responsible = NULL ;
|
||||
_succ = NULL ;
|
||||
_cxq = NULL ;
|
||||
FreeNext = NULL ;
|
||||
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
|
||||
_SpinFreq = 0 ;
|
||||
_SpinClock = 0 ;
|
||||
OwnerIsThread = 0 ;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当多个线程同时访问一段同步代码时,多个线程会先被存放在ContentionList和_EntryList 集合中,处于block状态的线程,都会被加入到该列表。接下来当线程获取到对象的Monitor时,Monitor是依靠底层操作系统的Mutex Lock来实现互斥的,线程申请Mutex成功,则持有该Mutex,其它线程将无法获取到该Mutex,竞争失败的线程会再次进入ContentionList被挂起。
|
||||
|
||||
如果线程调用wait() 方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/08/f78e6979c424cce677add97080811308.jpg" alt="">
|
||||
|
||||
看完上面的讲解,相信你对同步锁的实现原理已经有个深入的了解了。总结来说就是,同步锁在这种实现方式中,因Monitor是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。
|
||||
|
||||
## 锁升级优化
|
||||
|
||||
为了提升性能,JDK1.6引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的Java对象头实现了锁升级功能。
|
||||
|
||||
当Java对象被Synchronized关键字修饰成为同步锁后,围绕这个锁的一系列升级操作都将和Java对象头有关。
|
||||
|
||||
### Java对象头
|
||||
|
||||
在JDK1.6 JVM中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中Java对象头由Mark Word、指向类的指针以及数组长度三部分组成。
|
||||
|
||||
Mark Word记录了对象和锁有关的信息。Mark Word在64位JVM中的长度是64bit,我们可以一起看下64位JVM的存储结构是怎么样的。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/f8/fd86f1b5cbac1f652bea58b039fbc8f8.jpg" alt="">
|
||||
|
||||
锁升级功能主要依赖于Mark Word中的锁标志位和释放偏向锁标志位,Synchronized同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。下面我们就沿着这条优化路径去看下具体的内容。
|
||||
|
||||
### 1.偏向锁
|
||||
|
||||
偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。
|
||||
|
||||
偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的Mark Word中去判断一下是否有偏向锁指向它的ID,无需再进入Monitor去竞争对象了。**当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是01,“是否偏向锁”标志位设置为1,并且记录抢到锁的线程ID,表示进入偏向锁状态。**
|
||||
|
||||
一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。
|
||||
|
||||
**下图中红线流程部分为偏向锁获取和撤销流程:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/a2/d9f1e7fae6996a940e9471c47a455ba2.png" alt="">
|
||||
|
||||
因此,在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生stop the word后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加JVM参数关闭偏向锁来调优系统性能,示例代码如下:
|
||||
|
||||
```
|
||||
-XX:-UseBiasedLocking //关闭偏向锁(默认打开)
|
||||
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```
|
||||
-XX:+UseHeavyMonitors //设置重量级锁
|
||||
|
||||
```
|
||||
|
||||
### 2.轻量级锁
|
||||
|
||||
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行CAS操作获取锁,如果获取成功,直接替换Mark Word中的线程ID为自己的ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
|
||||
|
||||
轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
|
||||
|
||||
**下图中红线流程部分为升级轻量级锁及操作流程:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/f0/84f4d5dd908788fec0940431b4b912f0.png" alt="">
|
||||
|
||||
### 3.自旋锁与重量级锁
|
||||
|
||||
轻量级锁CAS抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短的时间内释放资源,那么进入阻塞状态的线程无疑又要申请锁资源。
|
||||
|
||||
JVM提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。
|
||||
|
||||
从JDK1.7开始,自旋锁默认启用,自旋次数由JVM设置决定,这里我不建议设置的重试次数过多,因为CAS重试操作意味着长时间地占用CPU。
|
||||
|
||||
自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会进入Monitor,之后会被阻塞在_WaitSet队列中。
|
||||
|
||||
**下图中红线流程部分为自旋后升级为重量级锁的流程:**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/99/2e912fc6de6faeb1713a10959e5f1e99.png" alt="">
|
||||
|
||||
在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。
|
||||
|
||||
在高负载、高并发的场景下,我们可以通过设置JVM参数来关闭自旋锁,优化系统性能,示例代码如下:
|
||||
|
||||
```
|
||||
-XX:-UseSpinning //参数关闭自旋锁优化(默认打开)
|
||||
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由jvm控制
|
||||
|
||||
```
|
||||
|
||||
## 动态编译实现锁消除/锁粗化
|
||||
|
||||
除了锁升级优化,Java还使用了编译器对锁进行优化。JIT 编译器在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程。
|
||||
|
||||
确认是的话,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所表示的锁的申请与释放的机器码,即消除了锁的使用。在 Java7 之后的版本就不需要手动配置了,该操作可以自动实现。
|
||||
|
||||
锁粗化同理,就是在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。
|
||||
|
||||
## 减小锁粒度
|
||||
|
||||
除了锁内部优化和编译器优化之外,我们还可以通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。
|
||||
|
||||
当我们的锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行度。
|
||||
|
||||
最经典的减小锁粒度的案例就是JDK1.8之前实现的ConcurrentHashMap版本。我们知道,HashTable是基于一个数组+链表实现的,所以在并发读写操作集合时,存在激烈的锁资源竞争,也因此性能会存在瓶颈。而ConcurrentHashMap就很很巧妙地使用了分段锁Segment来降低锁资源竞争,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/92/c8306510d7562eb96c6f6b22a75e7592.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
JVM在JDK1.6中引入了分级锁机制来优化Synchronized,当一个线程获取锁时,首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题;其次如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景;轻量级锁还使用了自旋锁来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。
|
||||
|
||||
减少锁竞争,是优化Synchronized同步锁的关键。我们应该尽量使Synchronized同步锁处于轻量级锁或偏向锁,这样才能提高Synchronized同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高Synchronized同步锁在自旋时获取锁资源的成功率,避免Synchronized同步锁升级为重量级锁。
|
||||
|
||||
这一讲我们重点了解了Synchronized同步锁优化,这里由于字数限制,也为了你能更好地理解内容,目录中12讲的内容我拆成了两讲,在下一讲中,我会重点讲解Lock同步锁的优化方法。
|
||||
|
||||
## 思考题
|
||||
|
||||
请问以下Synchronized同步锁对普通方法和静态方法的修饰有什么区别?
|
||||
|
||||
```
|
||||
// 修饰普通方法
|
||||
public synchronized void method1() {
|
||||
// code
|
||||
}
|
||||
|
||||
// 修饰静态方法
|
||||
public synchronized static void method2() {
|
||||
// code
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">
|
||||
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="13 | 多线程之锁优化(中):深入了解Lock同步锁的优化方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/74/85e37c3969ca6d0913e3d8219766cd74.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
今天这讲我们继续来聊聊锁优化。上一讲我重点介绍了在JVM层实现的Synchronized同步锁的优化方法,除此之外,在JDK1.5之后,Java还提供了Lock同步锁。那么它有什么优势呢?
|
||||
|
||||
相对于需要JVM隐式获取和释放锁的Synchronized同步锁,Lock同步锁(以下简称Lock锁)需要的是显示获取和释放锁,这就为获取和释放锁提供了更多的灵活性。Lock锁的基本操作是通过乐观锁来实现的,但由于Lock锁也会在阻塞时被挂起,因此它依然属于悲观锁。我们可以通过一张图来简单对比下两个同步锁,了解下各自的特点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/3d/8ec55dc637830f728e505c673fefde3d.jpg" alt="">
|
||||
|
||||
从性能方面上来说,在并发量不高、竞争不激烈的情况下,Synchronized同步锁由于具有分级锁的优势,性能上与Lock锁差不多;但在高负载、高并发的情况下,Synchronized同步锁由于竞争激烈会升级到重量级锁,性能则没有Lock锁稳定。
|
||||
|
||||
我们可以通过一组简单的性能测试,直观地对比下两种锁的性能,结果见下方,代码可以在[Github](https://github.com/nickliuchao/syncLockTest)上下载查看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/b4/5c71e1402407e8970f51f9253fb716b4.jpg" alt="">
|
||||
|
||||
通过以上数据,我们可以发现:Lock锁的性能相对来说更加稳定。那它与上一讲的Synchronized同步锁相比,实现原理又是怎样的呢?
|
||||
|
||||
## Lock锁的实现原理
|
||||
|
||||
Lock锁是基于Java实现的锁,Lock是一个接口类,常用的实现类有ReentrantLock、ReentrantReadWriteLock(RRW),它们都是依赖AbstractQueuedSynchronizer(AQS)类实现的。
|
||||
|
||||
AQS类结构中包含一个基于链表实现的等待队列(CLH队列),用于存储所有阻塞的线程,AQS中还有一个state变量,该变量对ReentrantLock来说表示加锁状态。
|
||||
|
||||
该队列的操作均通过CAS操作实现,我们可以通过一张图来看下整个获取锁的流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/22/33/222196b8c410ff4ffca7131faa19d833.jpg" alt="">
|
||||
|
||||
## 锁分离优化Lock同步锁
|
||||
|
||||
虽然Lock锁的性能稳定,但也并不是所有的场景下都默认使用ReentrantLock独占锁来实现线程同步。
|
||||
|
||||
我们知道,对于同一份数据进行读写,如果一个线程在读数据,而另一个线程在写数据,那么读到的数据和最终的数据就会不一致;如果一个线程在写数据,而另一个线程也在写数据,那么线程前后看到的数据也会不一致。这个时候我们可以在读写方法中加入互斥锁,来保证任何时候只能有一个线程进行读或写操作。
|
||||
|
||||
在大部分业务场景中,读业务操作要远远大于写业务操作。而在多线程编程中,读操作并不会修改共享资源的数据,如果多个线程仅仅是读取共享资源,那么这种情况下其实没有必要对资源进行加锁。如果使用互斥锁,反倒会影响业务的并发性能,那么在这种场景下,有没有什么办法可以优化下锁的实现方式呢?
|
||||
|
||||
### 1.读写锁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队列进行阻塞等待;若是,就应该判断当前线程获取写锁是否超过了最大次数,若超过,抛异常,反之更新同步状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/d1/1bba37b281d83cdf0c51095f473001d1.jpg" alt="">
|
||||
|
||||
**一个线程尝试获取读锁时,**同样会先判断同步状态state是否为0。如果state等于0,说明暂时没有其它线程获取锁,此时判断是否需要阻塞,如果需要阻塞,则进入CLH队列进行阻塞等待;如果不需要阻塞,则CAS更新同步状态为读状态。
|
||||
|
||||
如果state不等于0,会判断同步状态低16位,如果存在写锁,则获取读锁失败,进入CLH阻塞队列;反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试CAS同步状态,获取成功更新同步锁为读状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/46/52e77acc6999efbdf4113daaa5918d46.jpeg" alt="">
|
||||
|
||||
下面我们通过一个求平方的例子,来感受下RRW的实现,代码如下:
|
||||
|
||||
```
|
||||
public class TestRTTLock {
|
||||
|
||||
private double x, y;
|
||||
|
||||
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
// 读锁
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2.读写锁再优化之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 * currentY);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以发现:一个写线程获取写锁的过程中,首先是通过WriteLock获取一个票据stamp,WriteLock是一个独占锁,同时只有一个线程可以获取该锁,当一个线程获取该锁后,其它请求的线程必须等待,当没有线程持有读锁或者写锁的时候才可以获取到该锁。请求该锁成功后会返回一个stamp票据变量,用来表示该锁的版本,当释放该锁的时候,需要unlockWrite并传递参数stamp。
|
||||
|
||||
接下来就是一个读线程获取锁的过程。首先线程会通过乐观锁tryOptimisticRead操作获取票据stamp ,如果当前没有线程持有写锁,则返回一个非0的stamp版本信息。线程获取该stamp后,将会拷贝一份共享资源到方法栈,在这之前具体的操作都是基于方法栈的拷贝数据。
|
||||
|
||||
之后方法还需要调用validate,验证之前调用tryOptimisticRead返回的stamp在当前是否有其它线程持有了写锁,如果是,那么validate会返回0,升级为悲观锁;否则就可以使用该stamp版本的锁对数据进行操作。
|
||||
|
||||
相比于RRW,StampedLock获取读锁只是使用与或操作进行检验,不涉及CAS操作,即使第一次乐观锁获取失败,也会马上升级至悲观锁,这样就可以避免一直进行CAS操作带来的CPU占用性能的问题,因此StampedLock的效率更高。
|
||||
|
||||
## 总结
|
||||
|
||||
不管使用Synchronized同步锁还是Lock同步锁,只要存在锁竞争就会产生线程阻塞,从而导致线程之间的频繁切换,最终增加性能消耗。因此,如何降低锁竞争,就成为了优化锁的关键。
|
||||
|
||||
在Synchronized同步锁中,我们了解了可以通过减小锁粒度、减少锁占用时间来降低锁的竞争。在这一讲中,我们知道可以利用Lock锁的灵活性,通过锁分离的方式来降低锁竞争。
|
||||
|
||||
Lock锁实现了读写锁分离来优化读大于写的场景,从普通的RRW实现到读锁和写锁,到StampedLock实现了乐观读锁、悲观读锁和写锁,都是为了降低锁的竞争,促使系统的并发性能达到最佳。
|
||||
|
||||
## 思考题
|
||||
|
||||
StampedLock同RRW一样,都适用于读大于写操作的场景,StampedLock青出于蓝结果却不好说,毕竟RRW还在被广泛应用,就说明它还有StampedLock无法替代的优势。你知道StampedLock没有被广泛应用的原因吗?或者说它还存在哪些缺陷导致没有被广泛应用。
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">
|
||||
@@ -0,0 +1,119 @@
|
||||
<audio id="audio" title="14 | 多线程之锁优化(下):使用乐观锁优化并行操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/69/e436324730876a4c007692b766e45669.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
前两讲我们讨论了Synchronized和Lock实现的同步锁机制,这两种同步锁都属于悲观锁,是保护线程安全最直观的方式。
|
||||
|
||||
我们知道悲观锁在高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。那有没有可能实现一种非阻塞型的锁机制来保证线程的安全呢?答案是肯定的。今天我就带你学习下乐观锁的优化方法,看看怎么使用才能发挥它最大的价值。
|
||||
|
||||
## 什么是乐观锁
|
||||
|
||||
开始优化前,我们先来简单回顾下乐观锁的定义。
|
||||
|
||||
乐观锁,顾名思义,就是说在操作共享资源时,它总是抱着乐观的态度进行,它认为自己可以成功地完成操作。但实际上,当多个线程同时操作一个共享资源时,只有一个线程会成功,那么失败的线程呢?它们不会像悲观锁一样在操作系统中挂起,而仅仅是返回,并且系统允许失败的线程重试,也允许自动放弃退出操作。
|
||||
|
||||
所以,乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。更为重要的是,乐观锁没有因竞争造成的系统开销,所以在性能上也是更胜一筹。
|
||||
|
||||
## 乐观锁的实现原理
|
||||
|
||||
相信你对上面的内容是有一定的了解的,下面我们来看看乐观锁的实现原理,有助于我们从根本上总结优化方法。
|
||||
|
||||
CAS是实现乐观锁的核心算法,它包含了3个参数:V(需要更新的变量)、E(预期值)和N(最新值)。
|
||||
|
||||
只有当需要更新的变量等于预期值时,需要更新的变量才会被设置为最新值,如果更新值和预期值不同,则说明已经有其它线程更新了需要更新的变量,此时当前线程不做操作,返回V的真实值。
|
||||
|
||||
### 1.CAS如何实现原子操作
|
||||
|
||||
在JDK中的concurrent包中,atomic路径下的类都是基于CAS实现的。AtomicInteger就是基于CAS实现的一个线程安全的整型类。下面我们通过源码来了解下如何使用CAS实现原子操作。
|
||||
|
||||
我们可以看到AtomicInteger的自增方法getAndIncrement是用了Unsafe的getAndAddInt方法,显然AtomicInteger依赖于本地方法Unsafe类,Unsafe类中的操作方法会调用CPU底层指令实现原子操作。
|
||||
|
||||
```
|
||||
//基于CAS操作更新值
|
||||
public final boolean compareAndSet(int expect, int update) {
|
||||
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
|
||||
}
|
||||
//基于CAS操作增1
|
||||
public final int getAndIncrement() {
|
||||
return unsafe.getAndAddInt(this, valueOffset, 1);
|
||||
}
|
||||
|
||||
//基于CAS操作减1
|
||||
public final int getAndDecrement() {
|
||||
return unsafe.getAndAddInt(this, valueOffset, -1);
|
||||
|
||||
|
||||
```
|
||||
|
||||
### 2.处理器如何实现原子操作
|
||||
|
||||
CAS是调用处理器底层指令来实现原子操作,那么处理器底层又是如何实现原子操作的呢?
|
||||
|
||||
处理器和物理内存之间的通信速度要远慢于处理器间的处理速度,所以处理器有自己的内部缓存。如下图所示,在执行操作时,频繁使用的内存数据会缓存在处理器的L1、L2和L3高速缓存中,以加快频繁读取的速度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/f0/15ffcf63bf097a6db4826000f1923af0.png" alt="">
|
||||
|
||||
一般情况下,一个单核处理器能自我保证基本的内存操作是原子性的,当一个线程读取一个字节时,所有进程和线程看到的字节都是同一个缓存里的字节,其它线程不能访问这个字节的内存地址。
|
||||
|
||||
但现在的服务器通常是多处理器,并且每个处理器都是多核的。每个处理器维护了一块字节的内存,每个内核维护了一块字节的缓存,这时候多线程并发就会存在缓存不一致的问题,从而导致数据不一致。
|
||||
|
||||
这个时候,处理器提供了**总线锁定**和**缓存锁定**两个机制来保证复杂内存操作的原子性。
|
||||
|
||||
当处理器要操作一个共享变量的时候,其在总线上会发出一个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操作修改变量;当有多个写线程竞争的情况下,除了占用base变量的一个写线程之外,其它各个线程会将修改的变量写入到自己的槽cell[]数组中,最终结果可通过以下公式计算得出:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/bd/d1fad4bd9f40383a69b9eefda14c37bd.jpg" alt="">
|
||||
|
||||
我们可以发现,LongAdder在操作后的返回值只是一个近似准确的数值,但是LongAdder最终返回的是一个准确的数值, 所以在一些对实时性要求比较高的场景下,LongAdder并不能取代AtomicInteger或AtomicLong。
|
||||
|
||||
## 总结
|
||||
|
||||
在日常开发中,使用乐观锁最常见的场景就是数据库的更新操作了。为了保证操作数据库的原子性,我们常常会为每一条数据定义一个版本号,并在更新前获取到它,到了更新数据库的时候,还要判断下已经获取的版本号是否被更新过,如果没有,则执行该操作。
|
||||
|
||||
CAS乐观锁在平常使用时比较受限,它只能保证单个变量操作的原子性,当涉及到多个变量时,CAS就无能为力了,但前两讲讲到的悲观锁可以通过对整个代码块加锁来做到这点。
|
||||
|
||||
CAS乐观锁在高并发写大于读的场景下,大部分线程的原子操作会失败,失败后的线程将会不断重试CAS原子操作,这样就会导致大量线程长时间地占用CPU资源,给系统带来很大的性能开销。在JDK1.8中,Java新增了一个原子类LongAdder,它使用了空间换时间的方法,解决了上述问题。
|
||||
|
||||
11~13讲的内容,我详细地讲解了基于JVM实现的同步锁Synchronized,AQS实现的同步锁Lock以及CAS实现的乐观锁。相信你也很好奇,这三种锁,到底哪一种的性能最好,现在我们来对比下三种不同实现方式下的锁的性能。
|
||||
|
||||
鉴于脱离实际业务场景的性能对比测试没有意义,我们可以分别在“读多写少”“读少写多”“读写差不多”这三种场景下进行测试。又因为锁的性能还与竞争的激烈程度有关,所以除此之外,我们还将做三种锁在不同竞争级别下的性能测试。
|
||||
|
||||
综合上述条件,我将对四种模式下的五个锁Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock以及乐观锁LongAdder进行压测。
|
||||
|
||||
这里简要说明一下:我是在不同竞争级别的情况下,用不同的读写线程数组合出了四组测试,测试代码使用了计算并发计数器,读线程会去读取计数器的值,而写线程会操作变更计数器值,运行环境是4核的i7处理器。结果已给出,具体的测试代码可以点击[Github](https://github.com/nickliuchao/lockTest)查看下载。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/1e/d5906bf5be6a91cb4ab3e4511da2421e.jpg" alt="">
|
||||
|
||||
通过以上结果,我们可以发现:在读大于写的场景下,读写锁ReentrantReadWriteLock、StampedLock以及乐观锁的读写性能是最好的;在写大于读的场景下,乐观锁的性能是最好的,其它4种锁的性能则相差不多;在读和写差不多的场景下,两种读写锁以及乐观锁的性能要优于Synchronized和ReentrantLock。
|
||||
|
||||
## 思考题
|
||||
|
||||
我们在使用CAS操作的时候要注意的ABA问题指的是什么呢?
|
||||
|
||||
期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<audio id="audio" title="15 | 多线程调优(上):哪些操作导致了上下文切换?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/41/22c883bdac1839c6516c1579832de241.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
我们常说“实践是检验真理的唯一标准”,这句话不光在社会发展中可行,在技术学习中也同样适用。
|
||||
|
||||
记得我刚入职上家公司的时候,恰好赶上了一次抢购活动。这是系统重构上线后经历的第一次高并发考验,如期出现了大量超时报警,不过比我预料的要好一点,起码没有挂掉重启。
|
||||
|
||||
通过工具分析,我发现 cs(上下文切换每秒次数)指标已经接近了 60w ,平时的话最高5w。再通过日志分析,我发现了大量带有 wait() 的 Exception,由此初步怀疑是大量线程处理不及时导致的,进一步锁定问题是连接池大小设置不合理。后来我就模拟了生产环境配置,对连接数压测进行调节,降低最大线程数,最后系统的性能就上去了。
|
||||
|
||||
从实践中总结经验,我知道了在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行。线程数量设置太小,会导致程序不能充分地利用系统资源;线程数量设置太大,又可能带来资源的过度竞争,导致上下文切换带来额外的系统开销。
|
||||
|
||||
你看,其实很多经验就是这么一点点积累的。那么今天,我就想和你分享下“上下文切换”的相关内容,希望也能让你有所收获。
|
||||
|
||||
## 初识上下文切换
|
||||
|
||||
我们首先得明白,上下文切换到底是什么。
|
||||
|
||||
其实在单个处理器的时期,操作系统就能处理多线程并发任务。处理器给每个线程分配 CPU 时间片(Time Slice),线程在分配获得的时间片内执行任务。
|
||||
|
||||
CPU 时间片是 CPU 分配给每个线程执行的时间段,一般为几十毫秒。在这么短的时间内线程互相切换,我们根本感觉不到,所以看上去就好像是同时进行的一样。
|
||||
|
||||
时间片决定了一个线程可以连续占用处理器运行的时长。当一个线程的时间片用完了,或者因自身原因被迫暂停运行了,这个时候,另外一个线程(可以是同一个线程或者其它进程的线程)就会被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)。
|
||||
|
||||
具体来说,一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或者继续运行,就是“切入”。在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”了。
|
||||
|
||||
那上下文都包括哪些内容呢?具体来说,它包括了寄存器的存储内容以及程序计数器存储的指令内容。CPU 寄存器负责存储已经、正在和将要执行的任务,程序计数器负责存储CPU 正在执行的指令位置以及即将执行的下一条指令的位置。
|
||||
|
||||
在当前 CPU 数量远远不止一个的情况下,操作系统将 CPU 轮流分配给线程任务,此时的上下文切换就变得更加频繁了,并且存在跨 CPU 上下文切换,比起单核上下文切换,跨核切换更加昂贵。
|
||||
|
||||
## 多线程上下文切换诱因
|
||||
|
||||
在操作系统中,上下文切换的类型还可以分为进程间的上下文切换和线程间的上下文切换。而在多线程编程中,我们主要面对的就是线程间的上下文切换导致的性能问题,下面我们就重点看看究竟是什么原因导致了多线程的上下文切换。开始之前,先看下系统线程的生命周期状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/7e/14dde8a9486799ad1aa13aa06003757e.jpg" alt="">
|
||||
|
||||
结合图示可知,线程主要有“新建”(NEW)、“就绪”(RUNNABLE)、“运行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五种状态。到了Java层面它们都被映射为了NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINADTED等6种状态。
|
||||
|
||||
在这个运行过程中,线程由RUNNABLE转为非RUNNABLE的过程就是线程上下文切换。
|
||||
|
||||
一个线程的状态由 RUNNING 转为 BLOCKED ,再由 BLOCKED 转为 RUNNABLE ,然后再被调度器选中执行,这就是一个上下文切换的过程。
|
||||
|
||||
当一个线程从 RUNNING 状态转为 BLOCKED 状态时,我们称为一个线程的暂停,线程暂停被切出之后,操作系统会保存相应的上下文,以便这个线程稍后再次进入 RUNNABLE 状态时能够在之前执行进度的基础上继续执行。
|
||||
|
||||
当一个线程从 BLOCKED 状态进入到 RUNNABLE 状态时,我们称为一个线程的唤醒,此时线程将获取上次保存的上下文继续完成执行。
|
||||
|
||||
通过线程的运行状态以及状态间的相互切换,我们可以了解到,多线程的上下文切换实际上就是由多线程两个运行状态的互相切换导致的。
|
||||
|
||||
那么在线程运行时,线程状态由 RUNNING 转为 BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这又是什么诱发的呢?
|
||||
|
||||
我们可以分两种情况来分析,一种是程序本身触发的切换,这种我们称为自发性上下文切换,另一种是由系统或者虚拟机诱发的非自发性上下文切换。
|
||||
|
||||
自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。
|
||||
|
||||
- sleep()
|
||||
- wait()
|
||||
- yield()
|
||||
- join()
|
||||
- park()
|
||||
- synchronized
|
||||
- lock
|
||||
|
||||
非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。
|
||||
|
||||
这里重点说下“虚拟机垃圾回收为什么会导致上下文切换”。在 Java 虚拟机中,对象的内存都是由虚拟机中的堆分配的,在程序运行过程中,新的对象将不断被创建,如果旧的对象使用后不进行回收,堆内存将很快被耗尽。Java 虚拟机提供了一种回收机制,对创建后不再使用的对象进行回收,从而保证堆内存的可持续性分配。而这种垃圾回收机制的使用有可能会导致 stop-the-world 事件的发生,这其实就是一种线程暂停行为。
|
||||
|
||||
## 发现上下文切换
|
||||
|
||||
我们总说上下文切换会带来系统开销,那它带来的性能问题是不是真有这么糟糕呢?我们又该怎么去监测到上下文切换?上下文切换到底开销在哪些环节?接下来我将给出一段代码,来对比串联执行和并发执行的速度,然后一一解答这些问题。
|
||||
|
||||
```
|
||||
public class DemoApplication {
|
||||
public static void main(String[] args) {
|
||||
//运行多线程
|
||||
MultiThreadTester test1 = new MultiThreadTester();
|
||||
test1.Start();
|
||||
//运行单线程
|
||||
SerialTester test2 = new SerialTester();
|
||||
test2.Start();
|
||||
}
|
||||
|
||||
|
||||
static class MultiThreadTester extends ThreadContextSwitchTester {
|
||||
@Override
|
||||
public void Start() {
|
||||
long start = System.currentTimeMillis();
|
||||
MyRunnable myRunnable1 = new MyRunnable();
|
||||
Thread[] threads = new Thread[4];
|
||||
//创建多个线程
|
||||
for (int i = 0; i < 4; i++) {
|
||||
threads[i] = new Thread(myRunnable1);
|
||||
threads[i].start();
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
try {
|
||||
//等待一起运行完
|
||||
threads[i].join();
|
||||
} catch (InterruptedException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("multi thread exce time: " + (end - start) + "s");
|
||||
System.out.println("counter: " + counter);
|
||||
}
|
||||
// 创建一个实现Runnable的类
|
||||
class MyRunnable implements Runnable {
|
||||
public void run() {
|
||||
while (counter < 100000000) {
|
||||
synchronized (this) {
|
||||
if(counter < 100000000) {
|
||||
increaseCounter();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//创建一个单线程
|
||||
static class SerialTester extends ThreadContextSwitchTester{
|
||||
@Override
|
||||
public void Start() {
|
||||
long start = System.currentTimeMillis();
|
||||
for (long i = 0; i < count; i++) {
|
||||
increaseCounter();
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("serial exec time: " + (end - start) + "s");
|
||||
System.out.println("counter: " + counter);
|
||||
}
|
||||
}
|
||||
|
||||
//父类
|
||||
static abstract class ThreadContextSwitchTester {
|
||||
public static final int count = 100000000;
|
||||
public volatile int counter = 0;
|
||||
public int getCount() {
|
||||
return this.counter;
|
||||
}
|
||||
public void increaseCounter() {
|
||||
|
||||
this.counter += 1;
|
||||
}
|
||||
public abstract void Start();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行之后,看一下两者的时间测试结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/47/be3a4dbe5abbafc7121bae9ab46b1f47.jpg" alt="">
|
||||
|
||||
**通过数据对比我们可以看到:**串联的执行速度比并发的执行速度要快。这就是因为线程的上下文切换导致了额外的开销,使用 Synchronized 锁关键字,导致了资源竞争,从而引起了上下文切换,但即使不使用 Synchronized 锁关键字,并发的执行速度也无法超越串联的执行速度,这是因为多线程同样存在着上下文切换。Redis、NodeJS的设计就很好地体现了单线程串行的优势。
|
||||
|
||||
在 Linux 系统下,可以使用 Linux 内核提供的 vmstat 命令,来监视 Java 程序运行过程中系统的上下文切换频率,cs如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/29/73c791c705eb056ef3c44fd2bf8ecd29.jpg" alt="">
|
||||
|
||||
如果是监视某个应用的上下文切换,就可以使用 pidstat命令监控指定进程的 Context Switch 上下文切换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/f7/f5ecbf7c77a3ecffcc79d2854b3459f7.jpg" alt="">
|
||||
|
||||
由于 Windows 没有像 vmstat 这样的工具,在 Windows 下,我们可以使用 Process Explorer,来查看程序执行时,线程间上下文切换的次数。
|
||||
|
||||
至于系统开销具体发生在切换过程中的哪些具体环节,总结如下:
|
||||
|
||||
- 操作系统保存和恢复上下文;
|
||||
- 调度器进行线程调度;
|
||||
- 处理器高速缓存重新加载;
|
||||
- 上下文切换也可能导致整个高速缓存区被冲刷,从而带来时间开销。
|
||||
|
||||
## 总结
|
||||
|
||||
上下文切换就是一个工作的线程被另外一个线程暂停,另外一个线程占用了处理器开始执行任务的过程。系统和 Java 程序自发性以及非自发性的调用操作,就会导致上下文切换,从而带来系统开销。
|
||||
|
||||
线程越多,系统的运行速度不一定越快。那么我们平时在并发量比较大的情况下,什么时候用单线程,什么时候用多线程呢?
|
||||
|
||||
一般在单个逻辑比较简单,而且速度相对来非常快的情况下,我们可以使用单线程。例如,我们前面讲到的 Redis,从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。而在逻辑相对来说很复杂的场景,等待时间相对较长又或者是需要大量计算的场景,我建议使用多线程来提高系统的整体性能。例如,NIO 时期的文件读写操作、图像处理以及大数据分析等。
|
||||
|
||||
## 思考题
|
||||
|
||||
以上我们主要讨论的是多线程的上下文切换,前面我讲分类的时候还曾提到了进程间的上下文切换。那么你知道在多线程中使用Synchronized还会发生进程间的上下文切换吗?具体又会发生在哪些环节呢?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<audio id="audio" title="16 | 多线程调优(下):如何优化多线程上下文切换?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/3b/0f1426c7a97e8a47da7986ed6d5b2e3b.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
通过上一讲的讲解,相信你对上下文切换已经有了一定的了解了。如果是单个线程,在 CPU 调用之后,那么它基本上是不会被调度出去的。如果可运行的线程数远大于 CPU 数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其它线程能够使用 CPU ,这就会导致上下文切换。
|
||||
|
||||
还有,在多线程中如果使用了竞争锁,当线程由于等待竞争锁而被阻塞时,JVM 通常会将这个线程挂起,并允许它被交换出去。如果频繁地发生阻塞,CPU 密集型的程序就会发生更多的上下文切换。
|
||||
|
||||
那么问题来了,我们知道在某些场景下使用多线程是非常必要的,但多线程编程给系统带来了上下文切换,从而增加的性能开销也是实打实存在的。那么我们该如何优化多线程上下文切换呢?这就是我今天要和你分享的话题,我将重点介绍几种常见的优化方法。
|
||||
|
||||
## 竞争锁优化
|
||||
|
||||
大多数人在多线程编程中碰到性能问题,第一反应多是想到了锁。
|
||||
|
||||
多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。
|
||||
|
||||
第11~13讲中我曾集中讲过锁优化,我们知道锁的优化归根结底就是减少竞争。这讲中我们就再来总结下锁优化的一些方式。
|
||||
|
||||
### 1.减少锁的持有时间
|
||||
|
||||
我们知道,锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。如果是Synchronized同步锁资源,就不仅是带来线程间的上下文切换,还有可能会增加进程间的上下文切换。
|
||||
|
||||
在第12讲中,我曾分享过一些更具体的方法,例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作。
|
||||
|
||||
- 优化前
|
||||
|
||||
```
|
||||
public synchronized void mySyncMethod(){
|
||||
businesscode1();
|
||||
mutextMethod();
|
||||
businesscode2();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 优化后
|
||||
|
||||
```
|
||||
public void mySyncMethod(){
|
||||
businesscode1();
|
||||
synchronized(this)
|
||||
{
|
||||
mutextMethod();
|
||||
}
|
||||
businesscode2();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2.降低锁的粒度
|
||||
|
||||
同步锁可以保证对象的原子性,我们可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:
|
||||
|
||||
- 锁分离
|
||||
|
||||
与传统锁不同的是,读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规则是可以共享读,但只有一个写。
|
||||
|
||||
这样做的好处是,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。而传统的独占锁在没有区分读写锁的时候,读写操作一般是:读读互斥、读写互斥、写写互斥。所以在读远大于写的多线程场景中,锁分离避免了在高并发读情况下的资源竞争,从而避免了上下文切换。
|
||||
|
||||
- 锁分段
|
||||
|
||||
我们在使用锁来保证集合或者大对象原子性时,可以考虑将锁对象进一步分解。例如,我之前讲过的 Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段。
|
||||
|
||||
### 3.非阻塞乐观锁替代竞争锁
|
||||
|
||||
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优化
|
||||
|
||||
在 Java 中,我们可以通过配合调用 Object 对象的 wait()方法和 notify()方法或 notifyAll() 方法来实现线程间的通信。
|
||||
|
||||
在线程中调用 wait()方法,将阻塞等待其它线程的通知(其它线程调用notify()方法或notifyAll()方法),在线程中调用 notify()方法或 notifyAll()方法,将通知其它线程从 wait()方法处返回。
|
||||
|
||||
下面我们通过wait() / notify()来实现一个简单的生产者和消费者的案例,代码如下:
|
||||
|
||||
```
|
||||
public class WaitNotifyTest {
|
||||
public static void main(String[] args) {
|
||||
Vector<Integer> pool=new Vector<Integer>();
|
||||
Producer producer=new Producer(pool, 10);
|
||||
Consumer consumer=new Consumer(pool);
|
||||
new Thread(producer).start();
|
||||
new Thread(consumer).start();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 生产者
|
||||
* @author admin
|
||||
*
|
||||
*/
|
||||
class Producer implements Runnable{
|
||||
private Vector<Integer> pool;
|
||||
private Integer size;
|
||||
|
||||
public Producer(Vector<Integer> pool, Integer size) {
|
||||
this.pool = pool;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
for(;;){
|
||||
try {
|
||||
System.out.println("生产一个商品 ");
|
||||
produce(1);
|
||||
} catch (InterruptedException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
private void produce(int i) throws InterruptedException{
|
||||
while(pool.size()==size){
|
||||
synchronized (pool) {
|
||||
System.out.println("生产者等待消费者消费商品,当前商品数量为"+pool.size());
|
||||
pool.wait();//等待消费者消费
|
||||
}
|
||||
}
|
||||
synchronized (pool) {
|
||||
pool.add(i);
|
||||
pool.notifyAll();//生产成功,通知消费者消费
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 消费者
|
||||
* @author admin
|
||||
*
|
||||
*/
|
||||
class Consumer implements Runnable{
|
||||
private Vector<Integer> pool;
|
||||
public Consumer(Vector<Integer> pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
for(;;){
|
||||
try {
|
||||
System.out.println("消费一个商品");
|
||||
consume();
|
||||
} catch (InterruptedException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void consume() throws InterruptedException{
|
||||
synchronized (pool) {
|
||||
while(pool.isEmpty()) {
|
||||
System.out.println("消费者等待生产者生产商品,当前商品数量为"+pool.size());
|
||||
pool.wait();//等待生产者生产商品
|
||||
}
|
||||
}
|
||||
synchronized (pool) {
|
||||
pool.remove(0);
|
||||
pool.notifyAll();//通知生产者生产商品
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### wait/notify的使用导致了较多的上下文切换
|
||||
|
||||
结合以下图片,我们可以看到,在消费者第一次申请到锁之前,发现没有商品消费,此时会执行 Object.wait() 方法,这里会导致线程挂起,进入阻塞状态,这里为一次上下文切换。
|
||||
|
||||
当生产者获取到锁并执行notifyAll()之后,会唤醒处于阻塞状态的消费者线程,此时这里又发生了一次上下文切换。
|
||||
|
||||
被唤醒的等待线程在继续运行时,需要再次申请相应对象的内部锁,此时等待线程可能需要和其它新来的活跃线程争用内部锁,这也可能会导致上下文切换。
|
||||
|
||||
如果有多个消费者线程同时被阻塞,用notifyAll()方法,将会唤醒所有阻塞的线程。而某些商品依然没有库存,过早地唤醒这些没有库存的商品的消费线程,可能会导致线程再次进入阻塞状态,从而引起不必要的上下文切换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/0a/601517ef35af63a9e470b8531124bc0a.jpg" alt="">
|
||||
|
||||
### 优化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()。
|
||||
|
||||
## 合理地设置线程池大小,避免创建过多线程
|
||||
|
||||
线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。更多关于如何合理设置线程池数量的内容,我将在第18讲中详解。
|
||||
|
||||
还有一种情况就是,在有些创建线程池的方法里,线程数量设置不会直接暴露给我们。比如,用 Executors.newCachedThreadPool() 创建的线程池,该线程池会复用其内部空闲的线程来处理新提交的任务,如果没有,再创建新的线程(不受 MAX_VALUE 限制),这样的线程池如果碰到大量且耗时长的任务场景,就会创建非常多的工作线程,从而导致频繁的上下文切换。因此,这类线程池就只适合处理大量且耗时短的非阻塞任务。
|
||||
|
||||
## 使用协程实现非阻塞等待
|
||||
|
||||
相信很多人一听到协程(Coroutines),马上想到的就是Go语言。协程对于大部分 Java 程序员来说可能还有点陌生,但其在 Go 中的使用相对来说已经很成熟了。
|
||||
|
||||
协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升。协程在多线程业务上的运用,我会在第18讲中详述。
|
||||
|
||||
## 减少Java虚拟机的垃圾回收
|
||||
|
||||
我们在上一讲讲上下文切换的诱因时,曾提到过“垃圾回收会导致上下文切换”。
|
||||
|
||||
很多 JVM 垃圾回收器(serial收集器、ParNew收集器)在回收旧对象时,会产生内存碎片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程。因此减少 JVM 垃圾回收的频率可以有效地减少上下文切换。
|
||||
|
||||
## 总结
|
||||
|
||||
上下文切换是多线程编程性能消耗的原因之一,而竞争锁、线程间的通信以及过多地创建线程等多线程编程操作,都会给系统带来上下文切换。除此之外,I/O阻塞以及JVM的垃圾回收也会增加上下文切换。
|
||||
|
||||
总的来说,过于频繁的上下文切换会影响系统的性能,所以我们应该避免它。另外,我们还可以将上下文切换也作为系统的性能参考指标,并将该指标纳入到服务性能监控,防患于未然。
|
||||
|
||||
## 思考题
|
||||
|
||||
除了我总结中提到的线程间上下文切换的一些诱因,你还知道其它诱因吗?对应的优化方法又是什么?
|
||||
|
||||
期待在留言区看到你的分享。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/67/bbe343640d6b708832c4133ec53ed967.jpg" alt="unpreview">
|
||||
115
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/17 | 并发容器的使用:识别不同场景下最优容器.md
Normal file
115
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/17 | 并发容器的使用:识别不同场景下最优容器.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="17 | 并发容器的使用:识别不同场景下最优容器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/01/df2bbeb666b36cc6413bc1ba09fd5c01.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
在并发编程中,我们经常会用到容器。今天我要和你分享的话题就是:在不同场景下我们该如何选择最优容器。
|
||||
|
||||
## 并发场景下的Map容器
|
||||
|
||||
假设我们现在要给一个电商系统设计一个简单的统计商品销量TOP 10的功能。常规情况下,我们是用一个哈希表来存储商品和销量键值对,然后使用排序获得销量前十的商品。在这里,哈希表是实现该功能的关键。那么请思考一下,如果要你设计这个功能,你会使用哪个容器呢?
|
||||
|
||||
在07讲中,我曾详细讲过HashMap的实现原理,以及HashMap结构的各个优化细节。我说过HashMap的性能优越,经常被用来存储键值对。那么这里我们可以使用HashMap吗?
|
||||
|
||||
答案是不可以,我们切忌在并发场景下使用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将链表锁定,再执行接下来的操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/92/42b1a374f1d35789024291a4141d6192.png" alt="">
|
||||
|
||||
综上所述,我们在设计销量TOP10功能时,首选ConcurrentHashMap。
|
||||
|
||||
但要注意一点,虽然ConcurrentHashMap的整体性能要优于Hashtable,但在某些场景中,ConcurrentHashMap依然不能代替Hashtable。例如,在强一致的场景中ConcurrentHashMap就不适用,原因是ConcurrentHashMap中的get、size等方法没有用到锁,ConcurrentHashMap是弱一致性的,因此有可能会导致某次读无法马上获取到写入的数据。
|
||||
|
||||
### ConcurrentHashMap 🆚 ConcurrentSkipListMap
|
||||
|
||||
我们再看一个案例,我上家公司的操作系统中有这样一个功能,提醒用户手机卡实时流量不足。主要的流程是服务端先通过虚拟运营商同步用户实时流量,再通过手机端定时触发查询功能,如果流量不足,就弹出系统通知。
|
||||
|
||||
该功能的特点是用户量大,并发量高,写入多于查询操作。这时我们就需要设计一个缓存,用来存放这些用户以及对应的流量键值对信息。那么假设让你来实现一个简单的缓存,你会怎么设计呢?
|
||||
|
||||
你可能会考虑使用ConcurrentHashMap容器,但我在07讲中说过,该容器在数据量比较大的时候,链表会转换为红黑树。红黑树在并发情况下,删除和插入过程中有个平衡的过程,会牵涉到大量节点,因此竞争锁资源的代价相对比较高。
|
||||
|
||||
而跳跃表的操作针对局部,需要锁住的节点少,因此在并发场景下的性能会更好一些。你可能会问了,在非线程安全的Map容器中,我并没有看到基于跳跃表实现的SkipListMap呀?这是因为在非线程安全的Map容器中,基于红黑树实现的TreeMap在单线程中的性能表现得并不比跳跃表差。
|
||||
|
||||
因此就实现了在非线程安全的Map容器中,用TreeMap容器来存取大数据;在线程安全的Map容器中,用SkipListMap容器来存取大数据。
|
||||
|
||||
那么ConcurrentSkipListMap是如何使用跳跃表来提升容器存取大数据的性能呢?我们先来了解下跳跃表的实现原理。
|
||||
|
||||
**什么是跳跃表**
|
||||
|
||||
跳跃表是基于链表扩展实现的一种特殊链表,类似于树的实现,跳跃表不仅实现了横向链表,还实现了垂直方向的分层索引。
|
||||
|
||||
一个跳跃表由若干层链表组成,每一层都实现了一个有序链表索引,只有最底层包含了所有数据,每一层由下往上依次通过一个指针指向上层相同值的元素,每层数据依次减少,等到了最顶层就只会保留部分数据了。
|
||||
|
||||
跳跃表的这种结构,是利用了空间换时间的方法来提高了查询效率。程序总是从最顶层开始查询访问,通过判断元素值来缩小查询范围。我们可以通过以下几张图来了解下跳跃表的具体实现原理。
|
||||
|
||||
首先是一个初始化的跳跃表:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/80/42f26c3109f56803a8f19bf7fb181c80.jpg" alt="">
|
||||
|
||||
当查询key值为9的节点时,此时查询路径为:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/eb/21b0cc4361d662642bddbaf773931feb.jpg" alt="">
|
||||
|
||||
当新增一个key值为8的节点时,首先新增一个节点到最底层的链表中,根据概率算出level值,再根据level值新建索引层,最后链接索引层的新节点。新增节点和链接索引都是基于CAS操作实现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/1f/d8de8217c34be6af856773b63c7b7e1f.jpg" alt="">
|
||||
|
||||
当删除一个key值为7的结点时,首先找到待删除结点,将其value值设置为null;之后再向待删除结点的next位置新增一个标记结点,以便减少并发冲突;然后让待删结点的前驱节点直接越过本身指向的待删结点,直接指向后继结点,中间要被删除的结点最终将会被JVM垃圾回收处理掉;最后判断此次删除后是否导致某一索引层没有其它节点了,并视情况删除该层索引 。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/11/a76f5f8f4fcf23a6f0785d0412bfb911.jpg" alt="">
|
||||
|
||||
通过以上两个案例,我想你应该清楚了Hashtable、ConcurrentHashMap以及ConcurrentSkipListMap这三种容器的适用场景了。
|
||||
|
||||
如果对数据有强一致要求,则需使用Hashtable;在大部分场景通常都是弱一致性的情况下,使用ConcurrentHashMap即可;如果数据量在千万级别,且存在大量增删改操作,则可以考虑使用ConcurrentSkipListMap。
|
||||
|
||||
## 并发场景下的List容器
|
||||
|
||||
下面我们再来看一个实际生产环境中的案例。在大部分互联网产品中,都会设置一份黑名单。例如,在电商系统中,系统可能会将一些频繁参与抢购却放弃付款的用户放入到黑名单列表。想想这个时候你又会使用哪个容器呢?
|
||||
|
||||
首先用户黑名单的数据量并不会很大,但在抢购中需要查询该容器,快速获取到该用户是否存在于黑名单中。其次用户ID是整数类型,因此我们可以考虑使用数组来存储。那么ArrayList是否是你第一时间想到的呢?
|
||||
|
||||
我讲过ArrayList是非线程安全容器,在并发场景下使用很可能会导致线程安全问题。这时,我们就可以考虑使用Java在并发编程中提供的线程安全数组,包括Vector和CopyOnWriteArrayList。
|
||||
|
||||
Vector也是基于Synchronized同步锁实现的线程安全,Synchronized关键字几乎修饰了所有对外暴露的方法,所以在读远大于写的操作场景中,Vector将会发生大量锁竞争,从而给系统带来性能开销。
|
||||
|
||||
相比之下,CopyOnWriteArrayList是java.util.concurrent包提供的方法,它实现了读操作无锁,写操作则通过操作底层数组的新副本来实现,是一种读写分离的并发策略。我们可以通过以下图示来了解下CopyOnWriteArrayList的具体实现原理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/eb/4a7e3d6b77645b3258ba1680aa8087eb.jpg" alt="">
|
||||
|
||||
回到案例中,我们知道黑名单是一个读远大于写的操作业务,我们可以固定在某一个业务比较空闲的时间点来更新名单。
|
||||
|
||||
这种场景对写入数据的实时获取并没有要求,因此我们只需要保证最终能获取到写入数组中的用户ID就可以了,而CopyOnWriteArrayList这种并发数组容器无疑是最适合这类场景的了。
|
||||
|
||||
## 总结
|
||||
|
||||
在并发编程中,我们经常会使用容器来存储数据或对象。Java在JDK1.1到JDK1.8这个漫长的发展过程中,依据场景的变化实现了同类型的多种容器。我将今天的主要内容为你总结了一张表格,希望能对你有所帮助,也欢迎留言补充。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/99/6d6371fda6214743d69c54528cd8ff99.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
在抢购类系统中,我们经常会使用队列来实现抢购的排队等待,如果要你来选择或者设计一个队列,你会怎么考虑呢?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
264
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/18 | 如何设置线程池大小?.md
Normal file
264
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/18 | 如何设置线程池大小?.md
Normal file
@@ -0,0 +1,264 @@
|
||||
<audio id="audio" title="18 | 如何设置线程池大小?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/46/04/4614247beaaad69c1f8c0735f5dfd904.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
还记得我在16讲中说过“线程池的线程数量设置过多会导致线程竞争激烈”吗?今天再补一句,如果线程数量设置过少的话,还会导致系统无法充分利用计算机资源。那么如何设置才不会影响系统性能呢?
|
||||
|
||||
其实线程池的设置是有方法的,不是凭借简单的估算来决定的。今天我们就来看看究竟有哪些计算方法可以复用,线程池中各个参数之间又存在怎样的关系。
|
||||
|
||||
## 线程池原理
|
||||
|
||||
开始优化之前,我们先来看看线程池的实现原理,有助于你更好地理解后面的内容。
|
||||
|
||||
在HotSpot VM的线程模型中,Java线程被一对一映射为内核线程。Java在使用线程执行程序时,需要创建一个内核线程;当该Java线程被终止时,这个内核线程也会被回收。因此Java线程的创建与销毁将会消耗一定的计算机资源,从而增加系统的性能开销。
|
||||
|
||||
除此之外,大量创建线程同样会给系统带来性能问题,因为内存和CPU资源都将被线程抢占,如果处理不当,就会发生内存溢出、CPU使用率超负荷等问题。
|
||||
|
||||
为了解决上述两类问题,Java提供了线程池概念,对于频繁创建线程的业务场景,线程池可以创建固定的线程数量,并且在操作系统底层,轻量级进程将会把这些线程映射到内核。
|
||||
|
||||
线程池可以提高线程复用,又可以固定最大线程使用量,防止无限制地创建线程。当程序提交一个任务需要一个线程时,会去线程池中查找是否有空闲的线程,若有,则直接使用线程池中的线程工作,若没有,会去判断当前已创建的线程数量是否超过最大线程数量,如未超过,则创建新线程,如已超过,则进行排队等待或者直接抛出异常。
|
||||
|
||||
## 线程池框架Executor
|
||||
|
||||
Java最开始提供了ThreadPool实现了线程池,为了更好地实现用户级的线程调度,更有效地帮助开发人员进行多线程开发,Java提供了一套Executor框架。
|
||||
|
||||
这个框架中包括了ScheduledThreadPoolExecutor和ThreadPoolExecutor两个核心线程池。前者是用来定时执行任务,后者是用来执行被提交的任务。鉴于这两个线程池的核心原理是一样的,下面我们就重点看看ThreadPoolExecutor类是如何实现线程池的。
|
||||
|
||||
Executors实现了以下四种类型的ThreadPoolExecutor:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/69/8d3c1654add00cb63645f1332e4eb669.jpg" alt="">
|
||||
|
||||
Executors利用工厂模式实现的四种线程池,我们在使用的时候需要结合生产环境下的实际场景。不过我不太推荐使用它们,因为选择使用Executors提供的工厂类,将会忽略很多线程池的参数设置,工厂类一旦选择设置默认参数,就很容易导致无法调优参数设置,从而产生性能问题或者资源浪费。
|
||||
|
||||
这里我建议你使用ThreadPoolExecutor自我定制一套线程池。进入四种工厂类后,我们可以发现除了newScheduledThreadPool类,其它类均使用了ThreadPoolExecutor类进行实现,你可以通过以下代码简单看下该方法:
|
||||
|
||||
```
|
||||
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
|
||||
int maximumPoolSize,//线程池的最大线程数
|
||||
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
|
||||
TimeUnit unit,//时间单位
|
||||
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
|
||||
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
|
||||
RejectedExecutionHandler handler) //拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
|
||||
|
||||
```
|
||||
|
||||
我们还可以通过下面这张图来了解下线程池中各个参数的相互关系:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/e6/667149d07c494c29a23c1c08b9c0dbe6.jpg" alt="">
|
||||
|
||||
通过上图,我们发现线程池有两个线程数的设置,一个为核心线程数,一个为最大线程数。在创建完线程池之后,默认情况下,线程池中并没有任何线程,等到有任务来才创建线程去执行任务。
|
||||
|
||||
但有一种情况排除在外,就是调用prestartAllCoreThreads()或者prestartCoreThread()方法的话,可以提前创建等于核心线程数的线程数量,这种方式被称为预热,在抢购系统中就经常被用到。
|
||||
|
||||
当创建的线程数等于 corePoolSize 时,提交的任务会被加入到设置的阻塞队列中。当队列满了,会创建线程执行任务,直到线程池中的数量等于maximumPoolSize。
|
||||
|
||||
当线程数量已经等于maximumPoolSize时, 新提交的任务无法加入到等待队列,也无法创建非核心线程直接执行,我们又没有为线程池设置拒绝策略,这时线程池就会抛出RejectedExecutionException异常,即线程池拒绝接受这个任务。
|
||||
|
||||
当线程池中创建的线程数量超过设置的corePoolSize,在某些线程处理完任务后,如果等待keepAliveTime时间后仍然没有新的任务分配给它,那么这个线程将会被回收。线程池回收线程时,会对所谓的“核心线程”和“非核心线程”一视同仁,直到线程池中线程的数量等于设置的corePoolSize参数,回收过程才会停止。
|
||||
|
||||
即使是corePoolSize线程,在一些非核心业务的线程池中,如果长时间地占用线程数量,也可能会影响到核心业务的线程池,这个时候就需要把没有分配任务的线程回收掉。
|
||||
|
||||
我们可以通过allowCoreThreadTimeOut设置项要求线程池:将包括“核心线程”在内的,没有任务分配的所有线程,在等待keepAliveTime时间后全部回收掉。
|
||||
|
||||
我们可以通过下面这张图来了解下线程池的线程分配流程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/b0/b13aa36ef3b15a98fab1755ac36101b0.jpg" alt="">
|
||||
|
||||
## 计算线程数量
|
||||
|
||||
了解完线程池的实现原理和框架,我们就可以动手实践优化线程池的设置了。
|
||||
|
||||
我们知道,环境具有多变性,设置一个绝对精准的线程数其实是不大可能的,但我们可以通过一些实际操作因素来计算出一个合理的线程数,避免由于线程池设置不合理而导致的性能问题。下面我们就来看看具体的计算方法。
|
||||
|
||||
一般多线程执行的任务类型可以分为CPU密集型和I/O密集型,根据不同的任务类型,我们计算线程数的方法也不一样。
|
||||
|
||||
**CPU密集型任务:**这种任务消耗的主要是CPU资源,可以将线程数设置为N(CPU核心数)+1,比CPU核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用CPU的空闲时间。
|
||||
|
||||
下面我们用一个例子来验证下这个方法的可行性,通过观察CPU密集型任务在不同线程数下的性能情况就可以得出结果,你可以点击[Github](https://github.com/nickliuchao/threadpollsizetest)下载到本地运行测试:
|
||||
|
||||
```
|
||||
public class CPUTypeTest implements Runnable {
|
||||
|
||||
//整体执行时间,包括在队列中等待的时间
|
||||
List<Long> wholeTimeList;
|
||||
//真正执行时间
|
||||
List<Long> runTimeList;
|
||||
|
||||
private long initStartTime = 0;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param runTimeList
|
||||
* @param wholeTimeList
|
||||
*/
|
||||
public CPUTypeTest(List<Long> runTimeList, List<Long> wholeTimeList) {
|
||||
initStartTime = System.currentTimeMillis();
|
||||
this.runTimeList = runTimeList;
|
||||
this.wholeTimeList = wholeTimeList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断素数
|
||||
* @param number
|
||||
* @return
|
||||
*/
|
||||
public boolean isPrime(final int number) {
|
||||
if (number <= 1)
|
||||
return false;
|
||||
|
||||
|
||||
for (int i = 2; i <= Math.sqrt(number); i++) {
|
||||
if (number % i == 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算素数
|
||||
* @param number
|
||||
* @return
|
||||
*/
|
||||
public int countPrimes(final int lower, final int upper) {
|
||||
int total = 0;
|
||||
for (int i = lower; i <= upper; i++) {
|
||||
if (isPrime(i))
|
||||
total++;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
long start = System.currentTimeMillis();
|
||||
countPrimes(1, 1000000);
|
||||
long end = System.currentTimeMillis();
|
||||
|
||||
|
||||
long wholeTime = end - initStartTime;
|
||||
long runTime = end - start;
|
||||
wholeTimeList.add(wholeTime);
|
||||
runTimeList.add(runTime);
|
||||
System.out.println("单个线程花费时间:" + (end - start));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
测试代码在4核 intel i5 CPU机器上的运行时间变化如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/f9/c54224d1ed2d579a550650693c70e4f9.jpg" alt="">
|
||||
|
||||
综上可知:当线程数量太小,同一时间大量请求将被阻塞在线程队列中排队等待执行线程,此时CPU没有得到充分利用;当线程数量太大,被创建的执行线程同时在争取CPU资源,又会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。通过测试可知,4~6个线程数是最合适的。
|
||||
|
||||
**I/O密集型任务:**这种任务应用起来,系统会用大部分的时间来处理I/O交互,而线程在处理I/O的时间段内不会占用CPU来处理,这时就可以将CPU交出给其它线程使用。因此在I/O密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是2N。
|
||||
|
||||
这里我们还是通过一个例子来验证下这个公式是否可以标准化:
|
||||
|
||||
```
|
||||
public class IOTypeTest implements Runnable {
|
||||
|
||||
//整体执行时间,包括在队列中等待的时间
|
||||
Vector<Long> wholeTimeList;
|
||||
//真正执行时间
|
||||
Vector<Long> runTimeList;
|
||||
|
||||
private long initStartTime = 0;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param runTimeList
|
||||
* @param wholeTimeList
|
||||
*/
|
||||
public IOTypeTest(Vector<Long> runTimeList, Vector<Long> wholeTimeList) {
|
||||
initStartTime = System.currentTimeMillis();
|
||||
this.runTimeList = runTimeList;
|
||||
this.wholeTimeList = wholeTimeList;
|
||||
}
|
||||
|
||||
/**
|
||||
*IO操作
|
||||
* @param number
|
||||
* @return
|
||||
* @throws IOException
|
||||
*/
|
||||
public void readAndWrite() throws IOException {
|
||||
File sourceFile = new File("D:/test.txt");
|
||||
//创建输入流
|
||||
BufferedReader input = new BufferedReader(new FileReader(sourceFile));
|
||||
//读取源文件,写入到新的文件
|
||||
String line = null;
|
||||
while((line = input.readLine()) != null){
|
||||
//System.out.println(line);
|
||||
}
|
||||
//关闭输入输出流
|
||||
input.close();
|
||||
}
|
||||
|
||||
public void run() {
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
readAndWrite();
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
long end = System.currentTimeMillis();
|
||||
|
||||
|
||||
long wholeTime = end - initStartTime;
|
||||
long runTime = end - start;
|
||||
wholeTimeList.add(wholeTime);
|
||||
runTimeList.add(runTime);
|
||||
System.out.println("单个线程花费时间:" + (end - start));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
备注:由于测试代码读取2MB大小的文件,涉及到大内存,所以在运行之前,我们需要调整JVM的堆内存空间:-Xms4g -Xmx4g,避免发生频繁的FullGC,影响测试结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/88/0bb0fe79bc9fc3c386815e3d0bfcf088.jpg" alt="">
|
||||
|
||||
通过测试结果,我们可以看到每个线程所花费的时间。当线程数量在8时,线程平均执行时间是最佳的,这个线程数量和我们的计算公式所得的结果就差不多。
|
||||
|
||||
看完以上两种情况下的线程计算方法,你可能还想说,在平常的应用场景中,我们常常遇不到这两种极端情况,**那么碰上一些常规的业务操作,比如,通过一个线程池实现向用户定时推送消息的业务,我们又该如何设置线程池的数量呢?**
|
||||
|
||||
此时我们可以参考以下公式来计算线程数:
|
||||
|
||||
```
|
||||
线程数=N(CPU核数)*(1+WT(线程等待时间)/ST(线程时间运行时间))
|
||||
|
||||
```
|
||||
|
||||
我们可以通过JDK自带的工具VisualVM来查看WT/ST比例,以下例子是基于运行纯CPU运算的例子,我们可以看到:
|
||||
|
||||
```
|
||||
WT(线程等待时间)= 36788ms [线程运行总时间] - 36788ms[ST(线程时间运行时间)]= 0
|
||||
线程数=N(CPU核数)*(1+ 0 [WT(线程等待时间)]/36788ms[ST(线程时间运行时间)])= N(CPU核数)
|
||||
|
||||
```
|
||||
|
||||
这跟我们之前通过CPU密集型的计算公式N+1所得出的结果差不多。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/65/3214039ef8f15076084a363a9f0b0b65.jpg" alt="">
|
||||
|
||||
综合来看,我们可以根据自己的业务场景,从“N+1”和“2N”两个公式中选出一个适合的,计算出一个大概的线程数量,之后通过实际压测,逐渐往“增大线程数量”和“减小线程数量”这两个方向调整,然后观察整体的处理时间变化,最终确定一个具体的线程数量。
|
||||
|
||||
## 总结
|
||||
|
||||
今天我们主要学习了线程池的实现原理,Java线程的创建和消耗会给系统带来性能开销,因此Java提供了线程池来复用线程,提高程序的并发效率。
|
||||
|
||||
Java通过用户线程与内核线程结合的1:1线程模型来实现,Java将线程的调度和管理设置在了用户态,提供了一套Executor框架来帮助开发人员提高效率。Executor框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,可以说Executor框架为并发编程提供了一个完善的架构体系。
|
||||
|
||||
在不同的业务场景以及不同配置的部署机器中,线程池的线程数量设置是不一样的。其设置不宜过大,也不宜过小,要根据具体情况,计算出一个大概的数值,再通过实际的性能测试,计算出一个合理的线程数量。
|
||||
|
||||
我们要提高线程池的处理能力,一定要先保证一个合理的线程数量,也就是保证CPU处理线程的最大化。在此前提下,我们再增大线程池队列,通过队列将来不及处理的线程缓存起来。在设置缓存队列时,我们要尽量使用一个有界队列,以防因队列过大而导致的内存溢出问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
在程序中,除了并行段代码,还有串行段代码。那么当程序同时存在串行和并行操作时,优化并行操作是不是优化系统的关键呢?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
295
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/19 | 如何用协程来优化多线程业务?.md
Normal file
295
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/19 | 如何用协程来优化多线程业务?.md
Normal file
@@ -0,0 +1,295 @@
|
||||
<audio id="audio" title="19 | 如何用协程来优化多线程业务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/70/8cf1eb4d5700805bb5faed3a97546070.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
近一两年,国内很多互联网公司开始使用或转型Go语言,其中一个很重要的原因就是Go语言优越的性能表现,而这个优势与Go实现的轻量级线程Goroutines(协程Coroutine)不无关系。那么Go协程的实现与Java线程的实现有什么区别呢?
|
||||
|
||||
## 线程实现模型
|
||||
|
||||
了解协程和线程的区别之前,我们不妨先来了解下底层实现线程几种方式,为后面的学习打个基础。
|
||||
|
||||
实现线程主要有三种方式:轻量级进程和内核线程一对一相互映射实现的1:1线程模型、用户线程和内核线程实现的N:1线程模型以及用户线程和轻量级进程混合实现的N:M线程模型。
|
||||
|
||||
### 1:1线程模型
|
||||
|
||||
以上我提到的内核线程(Kernel-Level Thread, KLT)是由操作系统内核支持的线程,内核通过调度器对线程进行调度,并负责完成线程的切换。
|
||||
|
||||
我们知道在Linux操作系统编程中,往往都是通过fork()函数创建一个子进程来代表一个内核中的线程。一个进程调用fork()函数后,系统会先给新的进程分配资源,例如,存储数据和代码的空间。然后把原来进程的所有值都复制到新的进程中,只有少数值与原来进程的值(比如PID)不同,这相当于复制了一个主进程。
|
||||
|
||||
采用fork()创建子进程的方式来实现并行运行,会产生大量冗余数据,即占用大量内存空间,又消耗大量CPU时间用来初始化内存空间以及复制数据。
|
||||
|
||||
如果是一份一样的数据,为什么不共享主进程的这一份数据呢?这时候轻量级进程(Light Weight Process,即LWP)出现了。
|
||||
|
||||
相对于fork()系统调用创建的线程来说,LWP使用clone()系统调用创建线程,该函数是将部分父进程的资源的数据结构进行复制,复制内容可选,且没有被复制的资源可以通过指针共享给子进程。因此,轻量级进程的运行单元更小,运行速度更快。LWP是跟内核线程一对一映射的,每个LWP都是由一个内核线程支持。
|
||||
|
||||
### N:1线程模型
|
||||
|
||||
1:1线程模型由于跟内核是一对一映射,所以在线程创建、切换上都存在用户态和内核态的切换,性能开销比较大。除此之外,它还存在局限性,主要就是指系统的资源有限,不能支持创建大量的LWP。
|
||||
|
||||
N:1线程模型就可以很好地解决1:1线程模型的这两个问题。
|
||||
|
||||
该线程模型是在用户空间完成了线程的创建、同步、销毁和调度,已经不需要内核的帮助了,也就是说在线程创建、同步、销毁的过程中不会产生用户态和内核态的空间切换,因此线程的操作非常快速且低消耗。
|
||||
|
||||
### N:M线程模型
|
||||
|
||||
N:1线程模型的缺点在于操作系统不能感知用户态的线程,因此容易造成某一个线程进行系统调用内核线程时被阻塞,从而导致整个进程被阻塞。
|
||||
|
||||
N:M线程模型是基于上述两种线程模型实现的一种混合线程管理模型,即支持用户态线程通过LWP与内核线程连接,用户态的线程数量和内核态的LWP数量是N:M的映射关系。
|
||||
|
||||
**了解完这三个线程模型,你就可以清楚地了解到Go协程的实现与Java线程的实现有什么区别了。**
|
||||
|
||||
JDK 1.8 Thread.java 中 Thread#start 方法的实现,实际上是通过Native调用start0方法实现的;在Linux下, JVM Thread的实现是基于pthread_create实现的,而pthread_create实际上是调用了clone()完成系统调用创建线程的。
|
||||
|
||||
所以,目前Java在Linux操作系统下采用的是用户线程加轻量级线程,一个用户线程映射到一个内核线程,即1:1线程模型。由于线程是通过内核调度,从一个线程切换到另一个线程就涉及到了上下文切换。
|
||||
|
||||
而Go语言是使用了N:M线程模型实现了自己的调度器,它在N个内核线程上多路复用(或调度)M个协程,协程的上下文切换是在用户态由协程调度器完成的,因此不需要陷入内核,相比之下,这个代价就很小了。
|
||||
|
||||
## 协程的实现原理
|
||||
|
||||
协程不只在Go语言中实现了,其实目前大部分语言都实现了自己的一套协程,包括C#、erlang、python、lua、javascript、ruby等。
|
||||
|
||||
相对于协程,你可能对进程和线程更为熟悉。进程一般代表一个应用服务,在一个应用服务中可以创建多个线程,而协程与进程、线程的概念不一样,我们可以将协程看作是一个类函数或者一块函数中的代码,我们可以在一个主线程里面轻松创建多个协程。
|
||||
|
||||
程序调用协程与调用函数不一样的是,协程可以通过暂停或者阻塞的方式将协程的执行挂起,而其它协程可以继续执行。这里的挂起只是在程序中(用户态)的挂起,同时将代码执行权转让给其它协程使用,待获取执行权的协程执行完成之后,将从挂起点唤醒挂起的协程。 协程的挂起和唤醒是通过一个调度器来完成的。
|
||||
|
||||
结合下图,你可以更清楚地了解到基于N:M线程模型实现的协程是如何工作的。
|
||||
|
||||
假设程序中默认创建两个线程为协程使用,在主线程中创建协程ABCD…,分别存储在就绪队列中,调度器首先会分配一个工作线程A执行协程A,另外一个工作线程B执行协程B,其它创建的协程将会放在队列中进行排队等待。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/72/9b0a301337fa868eab1b9d32e6fcbd72.jpg" alt="">
|
||||
|
||||
当协程A调用暂停方法或被阻塞时,协程A会进入到挂起队列,调度器会调用等待队列中的其它协程抢占线程A执行。当协程A被唤醒时,它需要重新进入到就绪队列中,通过调度器抢占线程,如果抢占成功,就继续执行协程A,失败则继续等待抢占线程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/94/bebdfb057a9243e640515900eec4ed94.jpg" alt="">
|
||||
|
||||
相比线程,协程少了由于同步资源竞争带来的CPU上下文切换,I/O密集型的应用比较适合使用,特别是在网络请求中,有较多的时间在等待后端响应,协程可以保证线程不会阻塞在等待网络响应中,充分利用了多核多线程的能力。而对于CPU密集型的应用,由于在多数情况下CPU都比较繁忙,协程的优势就不是特别明显了。
|
||||
|
||||
## Kilim协程框架
|
||||
|
||||
虽然这么多的语言都实现了协程,但目前Java原生语言暂时还不支持协程。不过你也不用泄气,我们可以通过协程框架在Java中使用协程。
|
||||
|
||||
目前Kilim协程框架在Java中应用得比较多,通过这个框架,开发人员就可以低成本地在Java中使用协程了。
|
||||
|
||||
在Java中引入 [Kilim](https://github.com/kilim/kilim) ,和我们平时引入第三方组件不太一样,除了引入jar包之外,还需要通过Kilim提供的织入(Weaver)工具对Java代码编译生成的字节码进行增强处理,比如,识别哪些方式是可暂停的,对相关的方法添加上下文处理。通常有以下四种方式可以实现这种织入操作:
|
||||
|
||||
- 在编译时使用maven插件;
|
||||
- 在运行时调用kilim.tools.Weaver工具;
|
||||
- 在运行时使用kilim.tools.Kilim invoking调用Kilim的类文件;
|
||||
- 在main函数添加 if (kilim.tools.Kilim.trampoline(false,args)) return。
|
||||
|
||||
Kilim框架包含了四个核心组件,分别为:任务载体(Task)、任务上下文(Fiber)、任务调度器(Scheduler)以及通信载体(Mailbox)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/13/20e81165d99c5fc1a55424156e15ff13.jpg" alt="">
|
||||
|
||||
Task对象主要用来执行业务逻辑,我们可以把这个比作多线程的Thread,与Thread类似,Task中也有一个run方法,不过在Task中方法名为execute,我们可以将协程里面要做的业务逻辑操作写在execute方法中。
|
||||
|
||||
与Thread实现的线程一样,Task实现的协程也有状态,包括:Ready、Running、Pausing、Paused以及Done总共五种。Task对象被创建后,处于Ready状态,在调用execute()方法后,协程处于Running状态,在运行期间,协程可以被暂停,暂停中的状态为Pausing,暂停后的状态为Paused,暂停后的协程可以被再次唤醒。协程正常结束后的状态为Done。
|
||||
|
||||
Fiber对象与Java的线程栈类似,主要用来维护Task的执行堆栈,Fiber是实现N:M线程映射的关键。
|
||||
|
||||
Scheduler是Kilim实现协程的核心调度器,Scheduler负责分派Task给指定的工作者线程WorkerThread执行,工作者线程WorkerThread默认初始化个数为机器的CPU个数。
|
||||
|
||||
Mailbox对象类似一个邮箱,协程之间可以依靠邮箱来进行通信和数据共享。协程与线程最大的不同就是,线程是通过共享内存来实现数据共享,而协程是使用了通信的方式来实现了数据共享,主要就是为了避免内存共享数据而带来的线程安全问题。
|
||||
|
||||
## 协程与线程的性能比较
|
||||
|
||||
接下来,我们通过一个简单的生产者和消费者的案例,来对比下协程和线程的性能。可通过 [Github](https://github.com/nickliuchao/coroutine) 下载本地运行代码。
|
||||
|
||||
Java多线程实现源码:
|
||||
|
||||
```
|
||||
public class MyThread {
|
||||
private static Integer count = 0;//
|
||||
private static final Integer FULL = 10; //最大生产数量
|
||||
private static String LOCK = "lock"; //资源锁
|
||||
|
||||
public static void main(String[] args) {
|
||||
MyThread test1 = new MyThread();
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
List<Thread> list = new ArrayList<Thread>();
|
||||
for (int i = 0; i < 1000; i++) {//创建五个生产者线程
|
||||
Thread thread = new Thread(test1.new Producer());
|
||||
thread.start();
|
||||
list.add(thread);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 1000; i++) {//创建五个消费者线程
|
||||
Thread thread = new Thread(test1.new Consumer());
|
||||
thread.start();
|
||||
list.add(thread);
|
||||
}
|
||||
|
||||
try {
|
||||
for (Thread thread : list) {
|
||||
thread.join();//等待所有线程执行完
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("子线程执行时长:" + (end - start));
|
||||
}
|
||||
//生产者
|
||||
class Producer implements Runnable {
|
||||
public void run() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
synchronized (LOCK) {
|
||||
while (count == FULL) {//当数量满了时
|
||||
try {
|
||||
LOCK.wait();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
count++;
|
||||
System.out.println(Thread.currentThread().getName() + "生产者生产,目前总共有" + count);
|
||||
LOCK.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//消费者
|
||||
class Consumer implements Runnable {
|
||||
public void run() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
synchronized (LOCK) {
|
||||
while (count == 0) {//当数量为零时
|
||||
try {
|
||||
LOCK.wait();
|
||||
} catch (Exception e) {
|
||||
}
|
||||
}
|
||||
count--;
|
||||
System.out.println(Thread.currentThread().getName() + "消费者消费,目前总共有" + count);
|
||||
LOCK.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Kilim协程框架实现源码:
|
||||
|
||||
```
|
||||
public class Coroutine {
|
||||
|
||||
static Map<Integer, Mailbox<Integer>> mailMap = new HashMap<Integer, Mailbox<Integer>>();//为每个协程创建一个信箱,由于协程中不能多个消费者共用一个信箱,需要为每个消费者提供一个信箱,这也是协程通过通信来保证共享变量的线程安全的一种方式
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
if (kilim.tools.Kilim.trampoline(false,args)) return;
|
||||
Properties propes = new Properties();
|
||||
propes.setProperty("kilim.Scheduler.numThreads", "1");//设置一个线程
|
||||
System.setProperties(propes);
|
||||
long startTime = System.currentTimeMillis();
|
||||
for (int i = 0; i < 1000; i++) {//创建一千生产者
|
||||
Mailbox<Integer> mb = new Mailbox<Integer>(1, 10);
|
||||
new Producer(i, mb).start();
|
||||
mailMap.put(i, mb);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 1000; i++) {//创建一千个消费者
|
||||
new Consumer(mailMap.get(i)).start();
|
||||
}
|
||||
|
||||
Task.idledown();//开始运行
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
|
||||
System.out.println( Thread.currentThread().getName() + "总计花费时长:" + (endTime- startTime));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
//生产者
|
||||
public class Producer extends Task<Object> {
|
||||
|
||||
Integer count = null;
|
||||
Mailbox<Integer> mb = null;
|
||||
|
||||
public Producer(Integer count, Mailbox<Integer> mb) {
|
||||
this.count = count;
|
||||
this.mb = mb;
|
||||
}
|
||||
|
||||
public void execute() throws Pausable {
|
||||
count = count*10;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
mb.put(count);//当空间不足时,阻塞协程线程
|
||||
System.out.println(Thread.currentThread().getName() + "生产者生产,目前总共有" + mb.size() + "生产了:" + count);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
//消费者
|
||||
public class Consumer extends Task<Object> {
|
||||
|
||||
Mailbox<Integer> mb = null;
|
||||
|
||||
public Consumer(Mailbox<Integer> mb) {
|
||||
this.mb = mb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行
|
||||
*/
|
||||
public void execute() throws Pausable {
|
||||
Integer c = null;
|
||||
for (int i = 0; i < 10000; i++) {
|
||||
c = mb.get();//获取消息,阻塞协程线程
|
||||
|
||||
if (c == null) {
|
||||
System.out.println("计数");
|
||||
}else {
|
||||
System.out.println(Thread.currentThread().getName() + "消费者消费,目前总共有" + mb.size() + "消费了:" + c);
|
||||
c = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个案例中,我创建了1000个生产者和1000个消费者,每个生产者生产10个产品,1000个消费者同时消费产品。我们可以看到两个例子运行的结果如下:
|
||||
|
||||
```
|
||||
多线程执行时长:2761
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
协程执行时长:1050
|
||||
|
||||
```
|
||||
|
||||
通过上述性能对比,我们可以发现:在有严重阻塞的场景下,协程的性能更胜一筹。其实,I/O阻塞型场景也就是协程在Java中的主要应用。
|
||||
|
||||
## 总结
|
||||
|
||||
协程和线程密切相关,协程可以认为是运行在线程上的代码块,协程提供的挂起操作会使协程暂停执行,而不会导致线程阻塞。
|
||||
|
||||
协程又是一种轻量级资源,即使创建了上千个协程,对于系统来说也不是很大的负担,但如果在程序中创建上千个线程,那系统可真就压力山大了。可以说,协程的设计方式极大地提高了线程的使用率。
|
||||
|
||||
通过今天的学习,当其他人侃侃而谈Go语言在网络编程中的优势时,相信你不会一头雾水。学习Java的我们也不要觉得,协程离我们很遥远了。协程是一种设计思想,不仅仅局限于某一门语言,况且Java已经可以借助协程框架实现协程了。
|
||||
|
||||
但话说回来,协程还是在Go语言中的应用较为成熟,在Java中的协程目前还不是很稳定,重点是缺乏大型项目的验证,可以说Java的协程设计还有很长的路要走。
|
||||
|
||||
## 思考题
|
||||
|
||||
在Java中,除了Kilim框架,你知道还有其它协程框架也可以帮助Java实现协程吗?你使用过吗?
|
||||
|
||||
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
|
||||
|
||||
|
||||
204
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/20 | 答疑课堂:模块三热点问题解答.md
Normal file
204
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/20 | 答疑课堂:模块三热点问题解答.md
Normal file
@@ -0,0 +1,204 @@
|
||||
<audio id="audio" title="20 | 答疑课堂:模块三热点问题解答" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/a9/1a5716c9c47742935200869724cee5a9.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
不知不觉“多线程性能优化“已经讲完了,今天这讲我来解答下各位同学在这个模块集中提出的两大问题,第一个是有关监测上下文切换异常的命令排查工具,第二个是有关blockingQueue的内容。
|
||||
|
||||
也欢迎你积极留言给我,让我知晓你想了解的内容,或者说出你的困惑,我们共同探讨。下面我就直接切入今天的主题了。
|
||||
|
||||
## 使用系统命令查看上下文切换
|
||||
|
||||
在第15讲中我提到了上下文切换,其中有用到一些工具进行监测,由于篇幅关系就没有详细介绍,今天我就补充总结几个常用的工具给你。
|
||||
|
||||
### 1. Linux命令行工具之vmstat命令
|
||||
|
||||
vmstat是一款指定采样周期和次数的功能性监测工具,我们可以使用它监控进程上下文切换的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/71/13eeee053c553863b3bdd95c07cb3b71.jpg" alt="">
|
||||
|
||||
vmstat 1 3 命令行代表每秒收集一次性能指标,总共获取3次。以下为上图中各个性能指标的注释:
|
||||
|
||||
<li>**procs**<br>
|
||||
r:等待运行的进程数<br>
|
||||
b:处于非中断睡眠状态的进程数</li>
|
||||
<li>**memory**<br>
|
||||
swpd:虚拟内存使用情况<br>
|
||||
free:空闲的内存<br>
|
||||
buff:用来作为缓冲的内存数<br>
|
||||
cache:缓存大小</li>
|
||||
<li>**swap**<br>
|
||||
si:从磁盘交换到内存的交换页数量<br>
|
||||
so:从内存交换到磁盘的交换页数量</li>
|
||||
<li>**io**<br>
|
||||
bi:发送到块设备的块数<br>
|
||||
bo:从块设备接收到的块数</li>
|
||||
<li>**system**<br>
|
||||
in:每秒中断数<br>
|
||||
cs:每秒上下文切换次数</li>
|
||||
<li>**cpu**<br>
|
||||
us:用户CPU使用时间<br>
|
||||
sy:内核CPU系统使用时间<br>
|
||||
id:空闲时间<br>
|
||||
wa:等待I/O时间<br>
|
||||
st:运行虚拟机窃取的时间</li>
|
||||
|
||||
### 2. Linux命令行工具之pidstat命令
|
||||
|
||||
我们通过上述的vmstat命令只能观察到哪个进程的上下文切换出现了异常,那如果是要查看哪个线程的上下文出现了异常呢?
|
||||
|
||||
pidstat命令就可以帮助我们监测到具体线程的上下文切换。pidstat是Sysstat中一个组件,也是一款功能强大的性能监测工具。我们可以通过命令 yum install sysstat 安装该监控组件。
|
||||
|
||||
通过pidstat -help命令,我们可以查看到有以下几个常用参数可以监测线程的性能:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/d1/7a93cba1673119e4c9162a29e9875dd1.jpg" alt="">
|
||||
|
||||
常用参数:
|
||||
|
||||
- -u:默认参数,显示各个进程的cpu使用情况;
|
||||
- -r:显示各个进程的内存使用情况;
|
||||
- -d:显示各个进程的I/O使用情况;
|
||||
- -w:显示每个进程的上下文切换情况;
|
||||
- -p:指定进程号;
|
||||
- -t:显示进程中线程的统计信息
|
||||
|
||||
首先,通过pidstat -w -p pid 命令行,我们可以查看到进程的上下文切换:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/4f/3e6cee25e85826aa5d4f8f480535234f.jpg" alt="">
|
||||
|
||||
- cswch/s:每秒主动任务上下文切换数量
|
||||
- nvcswch/s:每秒被动任务上下文切换数量
|
||||
|
||||
之后,通过pidstat -w -p pid -t 命令行,我们可以查看到具体线程的上下文切换:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/6f/728b1634e3e9971307264b5736cb1c6f.jpg" alt="">
|
||||
|
||||
### 3. JDK工具之jstack命令
|
||||
|
||||
查看具体线程的上下文切换异常,我们还可以使用jstack命令查看线程堆栈的运行情况。jstack是JDK自带的线程堆栈分析工具,使用该命令可以查看或导出 Java 应用程序中的线程堆栈信息。
|
||||
|
||||
jstack最常用的功能就是使用 jstack pid 命令查看线程堆栈信息,通常是结合pidstat -p pid -t一起查看具体线程的状态,也经常用来排查一些死锁的异常。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/1d/0e61a2f4eb945f5a26bd7987d0babd1d.jpg" alt="">
|
||||
|
||||
每个线程堆栈的信息中,都可以查看到线程ID、线程状态(wait、sleep、running等状态)以及是否持有锁等。
|
||||
|
||||
我们可以通过jstack 16079 > /usr/dump将线程堆栈信息日志dump下来,之后打开dump文件,通过查看线程的状态变化,就可以找出导致上下文切换异常的具体原因。例如,系统出现了大量处于BLOCKED状态的线程,我们就需要立刻分析代码找出原因。
|
||||
|
||||
## 多线程队列
|
||||
|
||||
针对这讲的第一个问题,一份上下文切换的命令排查工具就总结完了。下面我来解答第二个问题,是在17讲中呼声比较高的有关blockingQueue的内容。
|
||||
|
||||
在Java多线程应用中,特别是在线程池中,队列的使用率非常高。Java提供的线程安全队列又分为了阻塞队列和非阻塞队列。
|
||||
|
||||
### 1.阻塞队列
|
||||
|
||||
我们先来看下阻塞队列。阻塞队列可以很好地支持生产者和消费者模式的相互等待,当队列为空的时候,消费线程会阻塞等待队列不为空;当队列满了的时候,生产线程会阻塞直到队列不满。
|
||||
|
||||
在Java线程池中,也用到了阻塞队列。当创建的线程数量超过核心线程数时,新建的任务将会被放到阻塞队列中。我们可以根据自己的业务需求来选择使用哪一种阻塞队列,阻塞队列通常包括以下几种:
|
||||
|
||||
- ** ArrayBlockingQueue:**一个基于数组结构实现的有界阻塞队列,按 FIFO(先进先出)原则对元素进行排序,使用ReentrantLock、Condition来实现线程安全;
|
||||
- ** LinkedBlockingQueue:**一个基于链表结构实现的阻塞队列,同样按FIFO (先进先出) 原则对元素进行排序,使用ReentrantLock、Condition来实现线程安全,吞吐量通常要高于ArrayBlockingQueue;
|
||||
- **PriorityBlockingQueue:**一个具有优先级的无限阻塞队列,基于二叉堆结构实现的无界限(最大值Integer.MAX_VALUE - 8)阻塞队列,队列没有实现排序,但每当有数据变更时,都会将最小或最大的数据放在堆最上面的节点上,该队列也是使用了ReentrantLock、Condition实现的线程安全;
|
||||
- **DelayQueue:**一个支持延时获取元素的无界阻塞队列,基于PriorityBlockingQueue扩展实现,与其不同的是实现了Delay延时接口;
|
||||
- **SynchronousQueue:**一个不存储多个元素的阻塞队列,每次进行放入数据时, 必须等待相应的消费者取走数据后,才可以再次放入数据,该队列使用了两种模式来管理元素,一种是使用先进先出的队列,一种是使用后进先出的栈,使用哪种模式可以通过构造函数来指定。
|
||||
|
||||
Java线程池Executors还实现了以下四种类型的ThreadPoolExecutor,分别对应以上队列,详情如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/da/59e1d01c8a60fe722aae01db86a913da.jpg" alt="">
|
||||
|
||||
### 2.非阻塞队列
|
||||
|
||||
我们常用的线程安全的非阻塞队列是ConcurrentLinkedQueue,它是一种无界线程安全队列(FIFO),基于链表结构实现,利用CAS乐观锁来保证线程安全。
|
||||
|
||||
下面我们通过源码来分析下该队列的构造、入列以及出列的具体实现。
|
||||
|
||||
**构造函数:**ConcurrentLinkedQueue由head 、tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点的引用 (next) 组成,节点与节点之间通过 next 关联,从而组成一张链表结构的队列。在队列初始化时, head 节点存储的元素为空,tail 节点等于 head 节点。
|
||||
|
||||
```
|
||||
public ConcurrentLinkedQueue() {
|
||||
head = tail = new Node<E>(null);
|
||||
}
|
||||
|
||||
private static class Node<E> {
|
||||
volatile E item;
|
||||
volatile Node<E> next;
|
||||
.
|
||||
.
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**入列:**当一个线程入列一个数据时,会将该数据封装成一个Node节点,并先获取到队列的队尾节点,当确定此时队尾节点的next值为null之后,再通过CAS将新队尾节点的next值设为新节点。此时p != t,也就是设置next值成功,然后再通过CAS将队尾节点设置为当前节点即可。
|
||||
|
||||
```
|
||||
public boolean offer(E e) {
|
||||
checkNotNull(e);
|
||||
//创建入队节点
|
||||
final Node<E> newNode = new Node<E>(e);
|
||||
//t,p为尾节点,默认相等,采用失败即重试的方式,直到入队成功
|
||||
for (Node<E> t = tail, p = t;;) {
|
||||
//获取队尾节点的下一个节点
|
||||
Node<E> q = p.next;
|
||||
//如果q为null,则代表p就是队尾节点
|
||||
if (q == null) {
|
||||
//将入列节点设置为当前队尾节点的next节点
|
||||
if (p.casNext(null, newNode)) {
|
||||
//判断tail节点和p节点距离达到两个节点
|
||||
if (p != t) // hop two nodes at a time
|
||||
//如果tail不是尾节点则将入队节点设置为tail。
|
||||
// 如果失败了,那么说明有其他线程已经把tail移动过
|
||||
casTail(t, newNode); // Failure is OK.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 如果p节点等于p的next节点,则说明p节点和q节点都为空,表示队列刚初始化,所以返回
|
||||
else if (p == q)
|
||||
p = (t != (t = tail)) ? t : head;
|
||||
else
|
||||
// Check for tail updates after two hops.
|
||||
p = (p != t && t != (t = tail)) ? t : q;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**出列:**首先获取head节点,并判断item是否为null,如果为空,则表示已经有一个线程刚刚进行了出列操作,然后更新head节点;如果不为空,则使用CAS操作将head节点设置为null,CAS就会成功地直接返回节点元素,否则还是更新head节点。
|
||||
|
||||
```
|
||||
public E poll() {
|
||||
// 设置起始点
|
||||
restartFromHead:
|
||||
for (;;) {
|
||||
//p获取head节点
|
||||
for (Node<E> h = head, p = h, q;;) {
|
||||
//获取头节点元素
|
||||
E item = p.item;
|
||||
//如果头节点元素不为null,通过cas设置p节点引用的元素为null
|
||||
if (item != null && p.casItem(item, null)) {
|
||||
// Successful CAS is the linearization point
|
||||
// for item to be removed from this queue.
|
||||
if (p != h) // hop two nodes at a time
|
||||
updateHead(h, ((q = p.next) != null) ? q : p);
|
||||
return item;
|
||||
}
|
||||
//如果p节点的下一个节点为null,则说明这个队列为空,更新head结点
|
||||
else if ((q = p.next) == null) {
|
||||
updateHead(h, p);
|
||||
return null;
|
||||
}
|
||||
//节点出队失败,重新跳到restartFromHead来进行出队
|
||||
else if (p == q)
|
||||
continue restartFromHead;
|
||||
else
|
||||
p = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ConcurrentLinkedQueue是基于CAS乐观锁实现的,在并发时的性能要好于其它阻塞队列,因此很适合作为高并发场景下的排队队列。
|
||||
|
||||
今天的答疑就到这里,如果你还有其它问题,请在留言区中提出,我会一一解答。最后欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他加入讨论。
|
||||
|
||||
|
||||
139
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/加餐 | 什么是数据的强、弱一致性?.md
Normal file
139
极客时间专栏/geek/Java性能调优实战/模块三 · 多线程性能调优/加餐 | 什么是数据的强、弱一致性?.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="加餐 | 什么是数据的强、弱一致性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/9c/81dbba7aac97cb60b0779ef63d5f069c.mp3"></audio>
|
||||
|
||||
你好,我是刘超。
|
||||
|
||||
在[第17讲](https://time.geekbang.org/column/article/103541)讲解并发容器的时候,我提到了“强一致性”和“弱一致性”。很多同学留言表示对这个概念没有了解或者比较模糊,今天这讲加餐就来详解一下。
|
||||
|
||||
说到一致性,其实在系统的很多地方都存在数据一致性的相关问题。除了在并发编程中保证共享变量数据的一致性之外,还有数据库的ACID中的C(Consistency 一致性)、分布式系统的CAP理论中的C(Consistency 一致性)。下面我们主要讨论的就是“并发编程中共享变量的一致性”。
|
||||
|
||||
在并发编程中,Java是通过共享内存来实现共享变量操作的,所以在多线程编程中就会涉及到数据一致性的问题。
|
||||
|
||||
我先通过一个经典的案例来说明下多线程操作共享变量可能出现的问题,假设我们有两个线程(线程1和线程2)分别执行下面的方法,x是共享变量:
|
||||
|
||||
```
|
||||
//代码1
|
||||
public class Example {
|
||||
int x = 0;
|
||||
public void count() {
|
||||
x++; //1
|
||||
System.out.println(x)//2
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/33/e1dfb18f71c76d1468fa94d43f8ca933.jpg" alt="">
|
||||
|
||||
如果两个线程同时运行,两个线程的变量的值可能会出现以下三种结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/9b/fb45f0c91af1a063d1f2db28dd21c49b.jpg" alt="">
|
||||
|
||||
## Java存储模型
|
||||
|
||||
2,1和1,2的结果我们很好理解,那为什么会出现以上1,1的结果呢?
|
||||
|
||||
我们知道,Java采用共享内存模型来实现多线程之间的信息交换和数据同步。在解释为什么会出现这样的结果之前,我们先通过下图来简单了解下Java的内存模型(第21讲还会详解),程序在运行时,局部变量将会存放在虚拟机栈中,而共享变量将会被保存在堆内存中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/8b/dfd02c98d495c4c4ed201ea7fe0e3f8b.jpg" alt="">
|
||||
|
||||
由于局部变量是跟随线程的创建而创建,线程的销毁而销毁,所以存放在栈中,由上图我们可知,Java栈数据不是所有线程共享的,所以不需要关心其数据的一致性。
|
||||
|
||||
共享变量存储在堆内存或方法区中,由上图可知,堆内存和方法区的数据是线程共享的。而堆内存中的共享变量在被不同线程操作时,会被加载到自己的工作内存中,也就是CPU中的高速缓存。
|
||||
|
||||
CPU 缓存可以分为一级缓存(L1)、二级缓存(L2)和三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。当 CPU 要读取一个缓存数据时,首先会从一级缓存中查找;如果没有找到,再从二级缓存中查找;如果还是没有找到,就从三级缓存或内存中查找。
|
||||
|
||||
如果是单核CPU运行多线程,多个线程同时访问进程中的共享数据,CPU 将共享变量加载到高速缓存后,不同线程在访问缓存数据的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。
|
||||
|
||||
如果是多核CPU运行多线程,每个核都有一个 L1缓存,如果多个线程运行在不同的内核上访问共享变量时,每个内核的L1缓存将会缓存一份共享变量。
|
||||
|
||||
假设线程A操作CPU从堆内存中获取一个缓存数据,此时堆内存中的缓存数据值为0,该缓存数据会被加载到L1缓存中,在操作后,缓存数据的值变为1,然后刷新到堆内存中。
|
||||
|
||||
在正好刷新到堆内存中之前,又有另外一个线程B将堆内存中为0的缓存数据加载到了另外一个内核的L1缓存中,此时线程A将堆内存中的数据刷新到了1,而线程B实际拿到的缓存数据的值为0。
|
||||
|
||||
此时,内核缓存中的数据和堆内存中的数据就不一致了,且线程B在刷新缓存到堆内存中的时候也将覆盖线程A中修改的数据。这时就产生了数据不一致的问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/05/3835ed3c7df4f859cc77de5d829dab05.jpg" alt="">
|
||||
|
||||
了解完内存模型之后,结合以上解释,我们就可以回过头来看看第一段代码中的运行结果是如何产生的了。看到这里,相信你可以理解图中1,1的运行结果了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/2e/dcaf4e9441871ca17f9b263cbd2b082e.jpg" alt="">
|
||||
|
||||
## 重排序
|
||||
|
||||
除此之外,在Java内存模型中,还存在重排序的问题。请看以下代码:
|
||||
|
||||
```
|
||||
//代码1
|
||||
public class Example {
|
||||
int x = 0;
|
||||
boolean flag = false;
|
||||
public void writer() {
|
||||
x = 1; //1
|
||||
flag = true; //2
|
||||
}
|
||||
|
||||
public void reader() {
|
||||
if (flag) { //3
|
||||
int r1 = x; //4
|
||||
System.out.println(r1==x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/a8/ae1dc00bfc5e3a751cc427841d14c9a8.jpg" alt="">
|
||||
|
||||
如果两个线程同时运行,线程2中的变量的值可能会出现以下两种可能:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/d6/ca6a5d4bb77ff67b1d30fcaac37c25d6.jpg" alt="">
|
||||
|
||||
现在一起来看看 r1=1 的运行结果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/33/0c1e6c9a2951b1ba87b32be15708f633.jpg" alt="">
|
||||
|
||||
那r1=0又是怎么获取的呢?我们再来看一个时序图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/17/880cbe050a2f65b1d9b457588f64f117.jpg" alt="">
|
||||
|
||||
在不影响运算结果的前提下,编译器有可能会改变顺序代码的指令执行顺序,特别是在一些可以优化的场景。
|
||||
|
||||
例如,在以下案例中,编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值。如果没有进行重排序优化,正常的执行顺序是步骤1\2\3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤1/3/2或者2/1/3,这样就能减少一次寄存器的存取次数。
|
||||
|
||||
```
|
||||
int x = 1;//步骤1:加载x变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
|
||||
boolean flag = true; //步骤2 加载flag变量的内存地址到寄存器中,加载true到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
|
||||
int y = x + 1;//步骤3 重新加载x变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
|
||||
|
||||
```
|
||||
|
||||
在 JVM 中,重排序是十分重要的一环,特别是在并发编程中。可 JVM 要是能对它们进行任意排序的话,也可能会给并发编程带来一系列的问题,其中就包括了一致性的问题。
|
||||
|
||||
## Happens-before规则
|
||||
|
||||
为了解决这个问题,Java提出了Happens-before规则来规范线程的执行顺序:
|
||||
|
||||
- 程序次序规则:在单线程中,代码的执行是有序的,虽然可能会存在运行指令的重排序,但最终执行的结果和顺序执行的结果是一致的;
|
||||
- 锁定规则:一个锁处于被一个线程锁定占用状态,那么只有当这个线程释放锁之后,其它线程才能再次获取锁操作;
|
||||
- volatile变量规则:如果一个线程正在写volatile变量,其它线程读取该变量会发生在写入之后;
|
||||
- 线程启动规则:Thread对象的start()方法先行发生于此线程的其它每一个动作;
|
||||
- 线程终结规则:线程中的所有操作都先行发生于对此线程的终止检测;
|
||||
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始;
|
||||
- 传递性:如果操作A happens-before 操作B,操作B happens-before操作C,那么操作A happens-before 操作C;
|
||||
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
|
||||
|
||||
**结合这些规则,我们可以将一致性分为以下几个级别:**
|
||||
|
||||
严格一致性(强一致性):所有的读写操作都按照全局时钟下的顺序执行,且任何时刻线程读取到的缓存数据都是一样的,Hashtable就是严格一致性;
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/6b/650c9490bad5962cfcdd4bedf3e41f6b.jpg" alt="">
|
||||
|
||||
顺序一致性:多个线程的整体执行可能是无序的,但对于单个线程而言执行是有序的,要保证任何一次读都能读到最近一次写入的数据,volatile可以阻止指令重排序,所以修饰的变量的程序属于顺序一致性;
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/c6/6d70a02e0a4fb51259bd6ffcac0f75c6.jpg" alt="">
|
||||
|
||||
弱一致性:不能保证任何一次读都能读到最近一次写入的数据,但能保证最终可以读到写入的数据,单个写锁+无锁读,就是弱一致性的一种实现。
|
||||
|
||||
今天的加餐到这里就结束了,如有疑问,欢迎留言给我。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user