一、Synchronized同步锁优化方法
1.6之前比较重量级,1.6后经过优化性能大大提升
使用Synchronized实现同步锁住要是两种方式:方法、代码块。
1.代码块
Synchronized在修饰同步代码块时,是由 monitorenter和monitorexit指令来实现同步的。进入monitorenter 指令后,线程将持有Monitor对象,退出monitorenter指令后,线程将释放该Monitor对象。
2.方法
当Synchronized修饰同步方法时,并没有发现monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。这是因为JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED访问标志。如果设置了该标志,执行线程将先持有Monitor对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该Mointor对象,当方法执行完成后,再释放该Monitor对象。
JVM中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。Monitor是由ObjectMonitor实现,而ObjectMonitor是由C++的ObjectMonitor.hpp文件实现。
当多个线程同时访问一段同步代码时,多个线程会先被存放在ContentionList和_EntryList 集合中,处于block状态的线程,都会被加入到该列表。接下来当线程获取到对象的Monitor时,Monitor是依靠底层操作系统的Mutex Lock来实现互斥的,线程申请Mutex成功,则持有该Mutex,其它线程将无法获取到该Mutex,竞争失败的线程会再次进入ContentionList被挂起。
如果线程调用wait() 方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex。
因为涉及到线程的阻塞和挂起等操作,这也是Synchronized比较重量级的原因。下面看看jdk源码是怎么进行优化的。
JDK1.6引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的Java对象头实现了锁升级功能。当Java对象被Synchronized关键字修饰成为同步锁后,围绕这个锁的一系列升级操作都将和Java对象头有关。对象头内容如下:
锁升级过程如下:
🌟🌟🌟一句话概括总结,通过一些方式去竞争锁,在竞争中逐渐提高锁的级别,代价也越来越大。一开始只需查询对象头,然后是CAS竞争,最后直接挂起阻塞线程。
锁的不同重量级对应着不同的场景,我们需要根据实际的业务情况去具体优化。
1.偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源,例如,在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换。
因此,在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生stop the word后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加JVM参数关闭偏向锁来调优系统性能,示例代码如下:
-XX:-UseBiasedLocking //关闭偏向锁(默认打开)
或
-XX:+UseHeavyMonitors //设置重量级锁
2.轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
3.自旋锁和重量级锁:在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。
在高负载、高并发的场景下,我们可以通过设置JVM参数来关闭自旋锁,优化系统性能,示例代码如下:
-XX:-UseSpinning //参数关闭自旋锁优化(默认打开)
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由jvm控制
4.动态编译优化,JIT编译器对锁的粒度增大或减小。例如,几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。而粒度减小的典型案例就是JDK8之前的ConcurrentHashMap中用的Segment分段锁,减小锁粒度实现增大并发量,避免锁被升级为重量级锁。
二、Lock同步锁优化方法
和synchronized的对比
Lock是一个接口,AQS(AbstractQueuedSynchronizer)是一个抽象类。Lock锁是基于Java实现的锁,Lock是一个接口类,常用的实现类有ReentrantLock、ReentrantReadWriteLock(RRW),它们都是依赖AbstractQueuedSynchronizer(AQS)类实现的。
AQS类结构中包含一个基于链表实现的等待队列(CLH队列),用于存储所有阻塞的线程,AQS中还有一个state变量,该变量对ReentrantLock来说表示加锁状态。
该队列的操作均通过CAS操作实现,我们可以通过一张图来看下整个获取锁的流程。简而言之,通过CAS竞争和队首节点去获得锁。
锁分离优化Lock同步锁,默认的ReentrantLock是独占锁,在大部分业务场景中,读业务操作要远远大于写业务操作。而在多线程编程中,读操作并不会修改共享资源的数据,如果多个线程仅仅是读取共享资源,那么这种情况下其实没有必要对资源进行加锁。如果使用互斥锁,反倒会影响业务的并发性能,那么在这种场景下,有没有什么办法可以优化下锁的实现方式呢?
1.读写锁ReentrantReadWriteLock
RRW也是继承AQS实现,内部维护了两个锁读锁和写锁,实现的关键是将AQS的同步变量state分为高16位和低16位,分别表示读写。
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的效率更高。
三、乐观锁优化并行操作
前面两种属于悲观锁,在高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。那有没有可能实现一种非阻塞型的锁机制来保证线程的安全呢?答案是肯定的:乐观锁。
乐观锁抱着能够成功的心态去操作共享资源,如果操作失败,不会像悲观锁一样被挂起,而是进行重试或者返回,因此性能开销更小,不会造成死锁、饥饿等故障。CAS想要操作成功,而竞争线程又多的时候,会不停的自旋造成CPU开销,因此我们需要优化这种情况。
在JDK1.8中,Java提供了一个新的原子类LongAdder。LongAdder在高并发场景下会比AtomicInteger和AtomicLong的性能更好,代价就是会消耗更多的内存空间。LongAdder 的核心思想是使用多个基础变量(buckets)来分散竞争,每个线程可能会被分配到不同的基础变量上进行累加操作,这样可以减少竞争压力。当需要得到最终的累加结果时,LongAdder 会将所有基础变量的值相加得到总和。例如,基础数值是5,其他几个线程要对其累加的值是1,1,0,1,-1,-2,1;最后返回的值是6,这里用到空间换时间的思想。
四、优化多线程上下文切换
结合图示可知,线程主要有“新建”(NEW)、“就绪”(RUNNABLE)、“运行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五种状态。到了Java层面它们都被映射为了NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINADTED等6种状态。
在这个运行过程中,线程由RUNNABLE转为非RUNNABLE的过程就是线程上下文切换。多线程的上下文切换实际上就是由多线程两个运行状态的互相切换导致的。切换需要保存寄存器和程序计数器的内容,除此之外还造成的系统开销有:
● 操作系统保存和恢复上下文;
● 调度器进行线程调度;
● 处理器高速缓存重新加载;
● 上下文切换也可能导致整个高速缓存区被冲刷,从而带来时间开销。
🌟🌟我们要将上下文切换作为系统性能指标纳入监控范围,那么怎么优化多线程上下文切换呢?
一、竞争锁优化
- 减少锁的持有时间,无关代码移出同步代码块;
- 降低锁的粒度,例如读写锁分离、分段锁;
- 非阻塞乐观锁代替竞争锁。volatile可以保证操作的可见性和有序性,且不会发生上下文切换,但是其不保证原子性,这是编码层面怎么降低竞争,其次JVM内部synchronized也对锁做了优化,四个粒度锁升级策略,也需要掌握其实现原理。
二、wait/notify优化
- wait会导致挂起进入阻塞状态,notify又唤醒,导致上下文切换,唤醒可以唤醒单个,而不是all;
- 持锁线程执行notify后应尽快释放锁,避免其他线程空等待;
- wait()挂起阻塞可以设置一个超时等待时间
三、合理设置线程池
- 线程数不宜设置过大,超过系统线程数可能会导致过多的上下文切换;
- 详细了解那些自带的线程池的优劣势,例如Executors.newCachedThreadPool() ,谨慎使用;
四、使用协程实现非阻塞等待
相信很多人一听到协程(Coroutines),马上想到的就是Go语言。协程对于大部分 Java 程序员来说可能还有点陌生,但其在 Go 中的使用相对来说已经很成熟了。协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升。
五、减少JVM垃圾回收
垃圾回收导致的STW(Stop The World)问题导致上下文切换,不赘述
五、并发容器的选择
- JDK1.7之前,hashmap在并发场景下会出现扩容死循环问题,1.8虽然修复了,但是在高并发场景下依然会有数据丢失和不准确等情况出现。
- 线程安全的Map容器:Hashtable、ConcurrentHashMap以及ConcurrentSkipListMap;Hashtable、ConcurrentHashMap是基于HashMap实现的,对于小数据量的存取比较有优势。ConcurrentSkipListMap是基于TreeMap的设计原理实现的,略有不同的是前者基于跳表实现,后者基于红黑树实现,ConcurrentSkipListMap的特点是存取平均时间复杂度是O(log(n)),适用于大数据量存取的场景,最常见的是基于跳跃表实现的数据量比较大的缓存。
- 线程安全的List容器:Vector、CopyOnWriteArrayList;
- 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将链表锁定,再执行接下来的操作。
- 并发List容器:
下面我们再来看一个实际生产环境中的案例。在大部分互联网产品中,都会设置一份黑名单。例如,在电商系统中,系统可能会将一些频繁参与抢购却放弃付款的用户放入到黑名单列表。想想这个时候你又会使用哪个容器呢?
首先用户黑名单的数据量并不会很大,但在抢购中需要查询该容器,快速获取到该用户是否存在于黑名单中。其次用户ID是整数类型,因此我们可以考虑使用数组来存储。那么ArrayList是否是你第一时间想到的呢?
我讲过ArrayList是非线程安全容器,在并发场景下使用很可能会导致线程安全问题。这时,我们就可以考虑使用Java在并发编程中提供的线程安全数组,包括Vector和CopyOnWriteArrayList。
Vector也是基于Synchronized同步锁实现的线程安全,Synchronized关键字几乎修饰了所有对外暴露的方法,所以在读远大于写的操作场景中,Vector将会发生大量锁竞争,从而给系统带来性能开销。
相比之下,CopyOnWriteArrayList是java.util.concurrent包提供的方法,它实现了读操作无锁,写操作则通过操作底层数组的新副本来实现,是一种读写分离的并发策略。我们可以通过以下图示来了解下CopyOnWriteArrayList的具体实现原理。
- 总结:
六、线程池的设置
Executors实现了以下四种类型的ThreadPoolExecutor:
Executors利用工厂模式实现的四种线程池,我们在使用的时候需要结合生产环境下的实际场景。不过我不太推荐使用它们,因为选择使用Executors提供的工厂类,将会忽略很多线程池的参数设置,工厂类一旦选择设置默认参数,就很容易导致无法调优参数设置,从而产生性能问题或者资源浪费。
这里建议使用ThreadPoolExecutor自我定制一套线程池。进入四种工厂类后,我们可以发现除了newScheduledThreadPool类,其它类均使用了ThreadPoolExecutor类进行实现,你可以通过以下代码简单看下该方法:
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler) //拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
线程池工作逻辑:
拒绝策略:拒绝并抛异常、直接丢弃、提交任务者自己执行、丢弃最老的任务;
最大线程数设置:
经典公式CPU密集型N+1,IO密集型2*N;
参考公式:
- 线程数 = N(CPU核数) * (1+WT(线程等待时间)/ST(线程时间运行时间))
- 综合来看,我们可以根据自己的业务场景,从“N+1”和“2N”两个公式中选出一个适合的,计算出一个大概的线程数量,之后通过实际压测,逐渐往“增大线程数量”和“减小线程数量”这两个方向调整,然后观察整体的处理时间变化,最终确定一个具体的线程数量。
七、协程的使用
协程不只在Go语言中实现了,其实目前大部分语言都实现了自己的一套协程,包括C#、erlang、python、lua、javascript、ruby等。
相对于协程,你可能对进程和线程更为熟悉。进程一般代表一个应用服务,在一个应用服务中可以创建多个线程,而协程与进程、线程的概念不一样,我们可以将协程看作是一个类函数或者一块函数中的代码,我们可以在一个主线程里面轻松创建多个协程。
程序调用协程与调用函数不一样的是,协程可以通过暂停或者阻塞的方式将协程的执行挂起,而其它协程可以继续执行。这里的挂起只是在程序中(用户态)的挂起,同时将代码执行权转让给其它协程使用,待获取执行权的协程执行完成之后,将从挂起点唤醒挂起的协程。 协程的挂起和唤醒是通过一个调度器来完成的。
结合下图,你可以更清楚地了解到基于N:M线程模型实现的协程是如何工作的。
假设程序中默认创建两个线程为协程使用,在主线程中创建协程ABCD…,分别存储在就绪队列中,调度器首先会分配一个工作线程A执行协程A,另外一个工作线程B执行协程B,其它创建的协程将会放在队列中进行排队等待。
-----------挂起,然后调度切换(发生在用户态)------------〉