锁的优化
发布日期:2021-09-25 11:48:21 浏览次数:4 分类:技术文章

本文共 5446 字,大约阅读时间需要 18 分钟。

对于单任务或者单线程的应用程序而言,主要资源消耗都花在任务本身,它既不需要维护并行数据结构间的一致性,也不需要为线程的切换和调度花费时间。但对于多线程的应用来说,系统出了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。在高并发环境下,激烈的锁今早会导致程序性能下降,自然有必要讨论一些有关锁的性能问题以及相关的一些注意事项

一,有助于提高锁性能的几点建议

1.减小锁持有时间

如果线程持有锁的时间很长,那么相对地,锁的竞争程度也就越激烈,eg:

public synchronized void syncMethos(){		othercode1();		mutextMethos();		othercode2();	}

上述代码中,假设只有mutextMethos()方法是需要同步的,其他两个无需同步且是重量级的方法,则会花费较长的CPU时间,此时,如果并发量大,使用这种对整个方法做同步的方案,会导致等待线程大量增加,因为一个线程在进入该方法时获得内部锁,只有在所有任务都执行完后才会释放锁。so,较为优化的方案,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统吞吐量

public void syncMethos(){		othercode1();		synchronized(this){			mutextMethos();		}		othercode2();	}

2.减小锁粒度

典型的使用场景就是ConcurrentHashMap

对于HashMap来说,最重要的两个方法即get和put,最自然的想法就是对整个HashMap加锁,必然可以得到一个线程安全的对象,但是这样的锁的粒度太大。ConcurrentHashMap,它内部进一步细分了若干个小的HashMap,称为段(segment),默认情况下,一个ConcurrentHashMap被进一步细分为16个段

如果需要在ConcurrentHashMap中增加一个表项,并不是将整个HashMap加锁,而是首先根据hashCode得到该表项应该被放在哪个段中,然后对该段加锁,并完成put操作,在多线程环境中,如果多个线程同时进行put操作,只要被加入的表项不放在同一个段中,则线程间便可以做到真正的并行

3.读写分离锁来替代独占锁

读写锁ReadWriteLock之前在写Lock的时候提到过,使用读写分离锁是减小锁粒度的一种特殊情况,是通过对系统功能点分割提高系统性能,应用在读多写少的场合

4.锁分离

典型的例子即LinkedBlockingQueue的实现,take函数和put函数分别实现了从队列中取数据和增加数据的功能,虽然两个函数都对队列进行修改操作,但由于LinkedBlockQueue基于链表,两个操作分别在队头和队尾,理论上说并不冲突

若使用独占锁,则take和put操作就不能真正并发,在运行时,它们会彼此等待释放锁资源,在JDK中,使用两把不同的锁:

/** Lock held by take, poll, etc */    private final ReentrantLock takeLock = new ReentrantLock();    /** Wait queue for waiting takes */    private final Condition notEmpty = takeLock.newCondition();    /** Lock held by put, offer, etc */    private final ReentrantLock putLock = new ReentrantLock();    /** Wait queue for waiting puts */    private final Condition notFull = putLock.newCondition();

take操作时,如果队列为空,则让当前线程等待在notEmpty上,新元素入队列时,则进行一次notEmpty上的通知

public E take() throws InterruptedException {        E x;        int c = -1;        final AtomicInteger count = this.count;        final ReentrantLock takeLock = this.takeLock;        takeLock.lockInterruptibly();        try {            while (count.get() == 0) {//如果当前没有可用数据,一直等待                notEmpty.await();//等待put操作的通知            }            x = dequeue();            c = count.getAndDecrement();            if (c > 1)                notEmpty.signal();//通知其他take操作        } finally {            takeLock.unlock();        }        if (c == capacity)            signalNotFull();//通知put操作,已有空余空间        return x;    }

相应的put

public void put(E e) throws InterruptedException {        if (e == null) throw new NullPointerException();        // Note: convention in all put/take/etc is to preset local var        // holding count negative to indicate failure unless set.        int c = -1;        Node
node = new Node
(e); final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly(); try { /* * Note that count is used in wait guard even though it is * not protected by lock. This works because count can * only decrease at this point (all other puts are shut * out by lock), and we (or some other waiting put) are * signalled if it ever changes from capacity. Similarly * for all other uses of count in other wait guards. */ while (count.get() == capacity) {//如果队列已经满了,等待 notFull.await(); } enqueue(node); c = count.getAndIncrement(); if (c + 1 < capacity) notFull.signal(); } finally { putLock.unlock(); } if (c == 0) signalNotEmpty();//插入成功后,通知take操作取数据 }

5.锁粗化

虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数

public void demoMethod(){		synchronized(lock){			//do sth.		}		//做其他不需要的同步的工作,但很快能执行完毕		synchronized(lock){			//do sth.		}	}

优化整合

public void demoMethod(){		//整合为一次请求		synchronized(lock){			//do sth.			//做其他不需要的同步的工作,但很快能执行完毕		}	}

二,锁优化

(本来想记个笔记再次翻的时候比较方便,结果昨天写了很久忘记保存。。。今天补上,今后希望不再犯这样的错误)

1.锁偏向

JDK1.6中引入的一项锁优化。当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示当前线程已经获得了锁,若测试失败,需测试下Mark Word中偏向锁的标识是否设置为1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

2.轻量级锁

JDK1.6之中加入的新型锁机制。如果偏向锁失败,虚拟机并不会立即挂起线程,使用一种称为轻量锁的优化手段,即将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁,如果线程获得轻量锁成功,则可顺利进入临界区,如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁

3.自旋锁

锁膨胀后,虚拟机为了避免线程真实的在操作系统层面挂起,还会做最后的努力,即自旋。系统会进行一次赌注:假设在不仅将来,线程可以得到这把锁,因此,虚拟机让当前线程做几个空循环(即自旋含义),在经过若干次循环后,如果可以得到锁,那么就进入临界区,如果还不能得到锁,才会真实地将线程在操作系统层面挂起

自旋锁在JDK1.4.2中就已经引入,不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK1.6中就已经改为默认开启了,自旋次数的默认值是10次,可以使用参数-XX:PreBlockSpin来更改

在JDK1.6中引入了自适应的自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环

4.锁消除

即通过对运行上下文的扫描,取出不可能存在共享资源竞争的锁,so,可能会有个问题,如果不可能存在竞争,为什么程序员还要加上锁?看个程序

public String [] createStrings(){		Vector
v=new Vector<>(); for(int i=0;i<100;i++){ v.add(Integer.toString(i)); } return v.toArray(new String[]{}); }

代码中的Vector,由于变量v只在函数中使用,因此它只是一个单纯的局部变量,局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问,so在这种情况下加锁同步是没有必要的,如果虚拟机检测到这种情况,就会将这些无用的锁操作去除

锁消除涉及到的关键技术为逃逸分析,即观察某一个变量是否会逃出某一个作用于,在本例中,变量v显然没有逃出函数之外,以此为基础虚拟机才可以大胆地将v内部的加锁操作去除,如果函数返回的不是String数组而是v本身,则认为变量v逃逸出了当前函数,也就是说v有可能被其他线程访问,若是这样,虚拟机就不能消除v中的锁操作

逃逸分析必须在-server模式下运行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析,使用-XX:+EliminateLocks参数可以打开锁消除

持续更新。。。

参考:

《深入理解java虚拟机》

《java高并发程序设计实战》

转载地址:https://blog.csdn.net/Autumn03/article/details/80938383 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:HTTP
下一篇:概念解析-死锁&饥饿&活锁

发表评论

最新留言

感谢大佬
[***.8.128.20]2024年04月03日 07时53分51秒