概述
目录
锁分离
锁粗化
减少锁占有时间
减少锁粒度
上两篇日志从锁的实现方面,总结了JVM对锁的一些实现和使用中膨胀的过程,从偏向锁、轻量级锁到自旋再到重量级锁,随着线程竞争越来越激烈,锁膨胀的也越来越厉害,不同锁的实现在不同场景下有它的优点和缺点,没错,在某些场景下,锁操作并不一定总能优化程序的,例如偏向锁在线程竞争激烈的场景下,需要不断变换自己的偏向状态,自旋锁在线程占用锁时间较长的场景下做的自旋操作通常是无用功,最后还是得不到锁,浪费了占用的CPU。所以,为了让程序执行的更加连贯,JVM还对锁做了一些优化。
锁分离
以前多线程日志里总结过一种锁分离是读写分离锁,如果用synchronized内部锁或ReentrantLock重入锁,所有线程争夺一把锁,那么这些线程之间应该是串行一个接一个执行的,但是读线程因为不会对数据做修改,不需要等待写线程的执行完成后再去读数据,所以用锁分离实现读写线程的分离可以提高运行效率。除了读写分离锁外,Java提供了一个无界队列LinkedBlockingQueue,它的实现是链表形式,take()和put()两个方法分别向队列中获得数据和放入数据,这两个操作是线程安全的,实现方式也和读写分离锁类似,分别使用了takeLock和putLock。我们知道对于队列来说,数据的取出和录入是分别在表头和表尾两端进行的,先进先出嘛,所以take()操作和put()操作基本上是互不影响的。但是如果每一个线程对于同一个队列,在操作时都先占有锁,那么就会出现这样的情况,假设线程A进行take操作,且占用了队列的锁,此时线程B进行put操作,哪怕线程B和线程A对于队列的操作不在同一端,B也需要等待A释放锁之后,才能进入队列,就像如果不使用读写分离锁,读线程在进行读操作前也需要等待写线程释放锁,哪怕读线程不会对数据进行修改。JVM对此的优化方式是,take操作和put操作分别使用takeLock和putLock,LinkedBlockingQueue中的take()操作实现中需要加takeLock,因此take线程之间是互相阻塞的,但不会阻塞put操作的线程,take()操作完成后释放takeLock,put()操作也如此,实现了take()和put()分离,互不影响,真正的并行执行。
锁粗化
另一种锁优化方式是锁粗化,最通俗的话来说就是减少把加锁操作分的那么细,这里有一点要注意,针对的是对同一个锁的粗化,举个例子来一边说一边解释:
public void LockTest() {
synchronized(lock) {
// ....
}
synchronized(lock) {
// ....
}
System.out.println();
synchronized(lock) {
// ....
}
}
public void LockTest() {
synchronized(lock) {
// ....
System.out.println();
}
}
示例中第2-13行进行了三次加锁操作,中间做了一步简单的输出语句,占有锁修改完临界区资源后,立刻释放锁,这没什么问题,但是频繁地对同一个锁进行申请和释放锁,是挺耗费时间的,因此JVM会对同一个锁多次连续加锁解锁操作进行优化,不分的那么细了,粗化成一次加锁解锁,减少了同步的次数,看上图的16-21行,这些优化都要在一个大前提下,就是锁粗化针对的是同一把锁。除了上述的连续请求锁形式外,还有一种循环请求锁形式也可以进行优化:
public void LockTest() {
for(int i=0; i<count; i++) {
synchronized(lock) {
// ....
}
}
}
public void LockTest() {
synchronized(lock) {
for(int i=0; i<count; i++) {
// ....
}
}
}
看到代码for循环里每次都要先申请锁在进行操作,这样的代码显然效率是很糟糕的,因为请求锁操作放在了循环体内部,如果我们像下面那样改造一下,把请求锁操作放在循环体外部,执行效率比起把锁放在内部有极大的提升,当然这个提升明不明显取决于for循环执行的次数。
总的来说,锁粗化的优化思想就是减少锁的请求和释放次数,但是就像偏向锁、自旋锁那样,锁粗化也并不是任何场景下都能提高程序执行效率的,如果一段代码中有的地方需要同步,有的地方不需要同步,我们把它们整合到了一起,就像上面第一个例子中,我们把三次加锁和一句简单的输出语句整合到了一起,输出语句不需要同步,且操作简单不耗费什么时间,这没什么问题,但是如果不是一句输出语句,而是一段需要耗费较长时间的代码段,那么被包裹进来后,线程占有锁的时间就会加长,对于其他没有拿到锁,在等待锁的线程来说,就需要等待更长的时间,导致锁竞争变激烈,所以,不能盲目地锁粗化,把多把锁整合到一起。
减少锁占有时间
从锁的占用时间方面来优化性能,这个比较容易理解,如果一个线程占有锁的时间很长,其他等待锁的线程慢慢变多,锁的竞争就会越来越激烈,如果能减少线程占有锁的时间,就可以让其他线程尽快得到锁,执行,让整个程序跑起来更流畅,举个例子,来看下面这段代码:
public void LockTest() {
simpleMethod();
LockMethod();
}
我们在方法LockTest()前加了synchronized修饰,也就是进入该同步代码块前先要获得该对象实例的锁,假设里面的simpleMethod()并不需要同步控制,但是执行需要花费大量的时间,而只有LockMethod()是需要同步控制,执行时间不会太长,这种情况下对实例方法LockTest()加synchronized同步控制,不好的地方就在于,不需要同步控制的地方执行时间大,导致线程占有LockeTest()实例锁时间长,只有当方法执行完后才会释放锁,那么其他需要该实例锁的线程就要等待很久。想要优化这种场景,可以从简单的方式入手,减少线程占有锁的时间,我们看到上面的代码段中,只有LockMethod()是需要同步控制的,那么我们可以不在LockeTest()上面加锁,而是改成这样:
public void LockTest() {
simpleMethod();
synchronized(this) {
LockMethod();
}
}
只在执行需要同步的代码块时才去加锁,这样即使前面simpleMethod()方法执行需要大量的时间,但此时线程也没有占有锁,这样局部加锁的方式可以很好的减少线程占有锁的时间,提高整个程序并行速度。
减少锁粒度
锁粒度就是指我们需要加锁的范围,这部分我理解的不多也不深,就简单总结下,例如我们有好几个List集合对象,如果我们想要获得每个集合的大小,先循环对每一个List都加锁,然后循环每一个调用size()方法求和,最后再释放所有的锁,这样做当然没问题,只是加锁的范围很大,每一个集合都获取它们的锁,最后求和完再释放,如果我们可以减少加锁的范围,也就是不是对每一个集合都加锁,可以减少锁竞争的情况,具体的做法是对每一个List调用size()求和时,都嫌使用CAS无锁比较操作,如果无锁操作失败了,我们才对该集合进行加锁,这样在其他线程竞争不激烈的情况下,锁的粒度可以大大减少。
最后
以上就是甜美酒窝为你收集整理的JVM中的锁(下):粒度、分离和锁粗化的全部内容,希望文章能够帮你解决JVM中的锁(下):粒度、分离和锁粗化所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复