java程序开发中一旦用到锁,就表示采用了阻塞形式的并发——一种最糟糕的并发等级。而锁优化就是希望在高并发多线程程序当中将涉及到有锁动作的相关代码尽可能的加以改进,使执行效率尽可能地得到提升。当然就算将这种用到了锁的代码优化到极致,其性能也无法超越无锁,毕竟锁会导致线程挂起(相对来说相当耗时及浪费资源)。但是我们要想办法让这种损耗降到最低,这是锁优化的出发点。
一般来说,java锁优化有如下思路或方法:
减少锁持有时间
减少锁粒度
锁分离
锁粗化
下面分别对锁优化的各种方法或思路作详细的介绍:
减少锁持有时间
锁在同一时间只能允许一个线程持有,其它想要占用锁的线程都得在临界区外等待锁的释放,这个等待的时间根据实际的应用及代码写法可长可短,比如下面的代码:
public synchronized void syncMethod(){
noneLockedCode1();//不需要加锁的代码
needLockedMethed();//需要线程安全的代码
noneLockedCode2();//不需要加锁的代码
}
在syncMethod方法中调用了三个方法,每个方法各代表一段代码块,若其中只有一个方法needLockedMethed()需要线程安全,其它两个方法就没必要放到同步代码块内执行,那么就可以将上面的代码进行如下改进:
public void syncMethod(){
noneLockedCode1();
synchronized(this){
needLockedMethed();
}
noneLockedCode2();
}
这样方法noneLockedCode1与noneLockedCode2的执行就不会占用锁的时间,减少了其它线程等待锁的时长,因此,也就提高了程序的性能,使锁的使用得到优化。
减少锁粒度
加锁可能是针对一个很重的对象(对象会被很多个线程加锁),这时若将大对象拆成更小粒度的小对象,就可以增加程序的并行度,降低多线程间锁的竞争,使加锁的成功率得到提高,因而达到锁优化的目的。
关于减少锁粒度的一个重要例子ConcurrentHashMap的实现。
我们知道HashMap若要实现线程安全,可以这么做:Collections.synchronizedMap(Map
public static Map synchronizedMap(Mapm) {
return new SynchronizedMap<>(m);
}
它的实现也很简单,仅仅是将get与set方法进行了互斥的同步,实现代码如下:
public V get(Object key) {
synchronized (mutex) {
return m.get(key);
}
}
public V put(K key, V value) {
synchronized (mutex) {
return m.put(key, value);
}
}
这样做会有什么问题?
这里的hashmap其实就是一个很重的对象,因为它里面可能会存储很多数据,当多个线程同时进来访问的时候,不管是读(get)还是写(put),都要拿到互斥对(mutex),因此读与写会相互阻塞,也就是说SynchronizedMap其实只支持对其中存放的一个对象进行读写,这无疑会带来很大的性能损耗,且map中数据越多、访问map的线程越多,损耗的性能就越大。
相对来讲,ConcurrentHashMap就是一个高性能的哈希表,这个高性能仅仅是因为它做了一个减小锁粒度的一个操作。在ConcurrentHashMap的源中,我们可以发现,它把整个Hashmap拆成了若干个小的segment,每一个segment都是一个小的hashmap,当有线程去操作里面的数据时,实时上操作的是被拆分后的某个小的segment,从而使ConcurrentHashMap允许多个线程同时进入,因此增加了并行度,达到了锁优化的目的。
锁分离
如果对系统有读和写的要求,普通锁(如syncronized)会导致读阻塞写、写也会阻塞读,同时读读与写写之间也会进行阻塞,锁优化的目的是要想办法使得阻塞尽可能的小,这里读写锁就会起来一定的优化作用。
读写锁的基本思想是将读与写进行分离,因为读不会改变数据,所以读与读之间不需要进行同步,其它读写、写读、写写之间的情况如下表:
读锁 | 写锁 | |
---|---|---|
读锁 | 可以访问 | 不可访问 |
写锁 | 不可访问 | 不可访问 |
表中可以看出,只要有写锁进入就需要做同步处理,但是对于大多数应用来说,读的场景要远远大于写的场景,因此一旦使用读写锁,在读多写少的场景中,就可以很好的提高系统的性能,这就是锁分离。锁分离之后在读锁与读锁之间就不再是阻塞的并发了,而是无等待的并发,这种锁优化方式将在一定场景下极大的提高系统的性能。
锁分离在java中应用延伸的一个例子就是LinkedBlockingQueue:
它充分利用热点分离的思想,从头部拿数据(读),添加数据(写)则在尾部,读与写这两者操作的数据在不同的部位,因此可以同时进行操作,使并发级别更高,除非队列或链表中只有一条数据。这就是读写分离思想的进一下延伸:只要操作不相互影响,锁就可以分离。
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
一种极端的情况如下:
public void doSomethingMethod(){
synchronized(lock){
//do some thing
}
//这是还有一些代码,做其它不需要同步的工作,但能很快执行完毕
synchronized(lock){
//do other thing
}
}
上面的代码是有两块需要同步操作的,但在这两块需要同步操作的代码之间,需要做一些其它的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗,合并后的代码如下:
public void doSomethingMethod(){
//进行锁粗化:整合成一次锁请求、同步、释放
synchronized(lock){
//do some thing
//做其它不需要同步但能很快执行完的工作
//do other thing
}
}
注意:这样做是有前提的,就是中间不需要同步的代码能够很快速地完成,如果不需要同步的代码需要花很长时间,就会导致同步块的执行需要花费很长的时间,这样做也就不合理了。
另一种需要锁粗化的极端的情况是:
for(int i=0;i
上面代码每次循环都会进行锁的请求、同步与释放,看起来貌似没什么问题,且在jdk内部会对这类代码锁的请求做一些优化,但是还不如把加锁代码写在循环体的外面,这样一次锁的请求就可以达到我们的要求,除非有特殊的需要:循环需要花很长时间,但其它线程等不起,要给它们执行的机会。
锁粗化后的代码如下:
synchronized(lock){
for(int i=0;i