美文网首页
Oh!老伙计,提高自己的并发技能,先从锁优化开始吧

Oh!老伙计,提高自己的并发技能,先从锁优化开始吧

作者: AGI大模型阿南 | 来源:发表于2020-09-18 10:55 被阅读0次

    锁是最常用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序的性能下降。

    对于单任务或者单线程的应用而言,其主要资源消耗都花在任务本身,它既不需要维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。对于多线程应用来说,系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。并行计算之所以能提高系统的性能,并不是因为它"少干活"了,而是因为并行计算可以更合理地进行任务调度,充分利用各个CPU资源。

    如何提高锁性能

    减少锁持有时间

    对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果锁的持有锁时间越长,那么锁的竞争程度也就越激烈。

    简单来讲就是:要100个人填写信息表,但是只有一根笔,每个人如果没想好怎么填,那么每个人持有笔的时间就会很长,那么总的时间就会变长。

    因此减少对某个锁的持有时间,以减少线程间互斥。例如下面这段代码:

    public synchronized void synMethod(){    method1();    mainMethod();    method2();}
    

    上面那段代码中,只有mainMethod()方法需要做同步控制,而method1()和method2()不需要做同步控制,那么上面那段在高并发的情况下对整个方法都进行了同步控制,如果method1()和method2()两个方法的耗时长,那么会导致整个程序的执行时间变长。因此我们可以选择下面这样优化:

    public void synMethod(){    method1();    synchronized(this){     mainMethod();       }    method2();}
    

    这样做的好处就是,只针对mainMethod()方法做了同步控制,锁占用的时间相对较短,因此能够有较高的并发度。较少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力

    减小锁粒度

    减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现。对ConcurrentHashMap有所了解的小伙伴应该知道,传统的HashTable之所以是线程安全的就是因为它是对整个方法加锁。而ConcurrentHashMap的性能比较高是因为它内部细分了若干个小的HashMap,称之为段(SEGMENT)。在默认情况下,一个ConcurrentHashMap类可以细分为16个端,性能相当于提升了16倍。

    在ConcurrentHashMap中增加一个数据,并不是对整个HashMap加锁,而是首先根据hashcode得出应该被存放在哪个段中,然后对该段加锁,并完成put()操作。当多个线程进行put()操作的时候,如果锁的不是同一个段,那么就可以实现并行操作。

    但是,减小锁粒度会带来一个新的问题:当系统需要取得全局锁时,其消耗的资源会比较多。例如:当ConcurrentHashMap调用size()方法时,需要或者所有子段的锁。虽然事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种方式,但是在高并发的情况下,ConcurrentHashMap的性能依然要弱于同步的HashMap。

    减小锁粒度,就是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力

    用读写锁来替换独占锁

    读写分离锁可以有效地帮助减少锁竞争,提高系统性能。比如:A1、A2、A3三个线程进行写操作,B1、B2、B3三个线程进行读操作,如果使用重入锁或者内部锁,那么所有读之间,读与写之间,写之间都是串行操作。但是因为读操作并不会造成数据的完整性破坏,因此这种等待是不合理的。

    因此可以使用读写分离锁ReadWriteLock来提高系统的性能。使用示例如下:

    image.png

    锁粗化

    通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,在使用完公共资源后,应该立即释放锁,只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。

    错误示例

    public void synMethod(){    synchronized(this){        method1();    }    synchronized(this){        method2();    }}
    

    优化后

    public void synMethod(){    synchronized(this){        method1();        method2();    }}
    

    尤其是在循环中要注意锁的粗化

    错误示例

    public void synMethod()
    {
        synchronized (this) {
            method1();
        }    synchronized (this) {
            method2();
        }
    

    优化后

    synchronized(lock){    for (int i = 1; i < n; i++) {     //do sth ...          }}
    

    JVM进行的锁优化

    [图片上传失败...(image-622001-1600397244769)] image.png

    偏向锁

    锁偏向是一种针对加锁操作的优化手段。核心思想:如果一个线程获得了一个锁,那么这个锁就进入了偏向模式,当这个线程释放完这个锁后,下次同其他线程再次请求时,无须在做任何同步操作。这样就节省了大量的锁申请相关操作。

    但是在锁竞争比较激烈的场合,效果不佳,因为在竞争激烈的场合,最有可能的情况就是每次都是不同的线程来请求,这样偏向模式会失效,因此还不如不启用偏向锁。可以通过 JVM参数 -XX:+UseBiasedLocking开启偏向锁。

    轻量级锁

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

    自旋锁

    锁膨胀后,为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力——自旋锁。当前线程暂时获取不到锁,但是如果简单粗暴地将这个线程挂起是一种得不偿失的操作,因此虚拟机会让当前线程做几个空循环,在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区

    重量级锁

    如果经过自旋还不能获得锁,才会真的将线程在操作系统层面挂起,升级为 重量级锁 **

    锁消除

    Java虚拟机在 JIT 编译时,会通过对运行上下文进行扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

    public String[] createArrays()
    {
        Vector<Integer> vector = new Vector<>();
        for ( int i = 1; i < 100; i++ )
        {
            vector.add( i );
        }
        return(vector.toArray( new String[] {} ) );
    }
    

    上面一段代码中,因为 vector 这个变量是定义在createArrays()这个方法中,是一个局部变量,在线程栈中分配的,属于线程私有的数据,因此不存在资源竞争的情况。而Vector内部所有加锁同步都是没有必要的,如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

    锁消除涉及的一项关键技术为逃逸分析,所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。在上面例子中,变量vector 没有逃出createArrays()这个函数的方位,因此虚拟机才会就将这个变量的加锁操作去除。如果 createArrays()返回的不是 String数组,而是 vector 本身,那么就认为变量 vector 逃出了当前函数,会被其他线程所访问到。例如下面代码:

    public Vector<Integer> createList()
    {
        Vector<Integer> vector = new Vector<>();
        for ( int i = 1; i < 100; i++ )
        {
            vector.add( i );
        }
        return(vector);}
    

    ThreadLocal

    除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。简单来讲就是:要100个人填写信息表,我们可以分配100根笔给他们填写,人手一根,那么填写的速度也将大大增加。

    image.png

    上面这个代码,如果没有同步控制则会出现java.lang.NumberFormatException: multiple points和java.lang.NumberFormatException: For input string: ""异常,因为SimpleDateFormat不是线程安全的,除非加锁控制。但是除了加锁我们还有没有其他方法呢,答案是有的,那就是使用ThreadLocal,每个线程分配一个SimpleDateFormat。

    image.png

    为每一个线程分配不同的对象,需要在应用层面保证 ThreadLocal 只起到了简单的容器作用

    ThreadLocal的实现原理

    set()方法:

    public void set( T value )
    {
        Thread t = Thread.currentThread();    ThreadLocalMap map = getMap( t );    if ( map != null )
            map.set( this, value );
        else
            createMap( t, value );
    }
    

    先获取当前线程对象,然后通过getMap()方法拿到线程的ThreadLocalMap,并将值存入ThreadLocalMap中。可以简单把ThreadLocalMap理解为一个Map,其中key为当前线程对象,value便是我们所需要的值

    get()方法:

    public T get()
    {
        Thread t = Thread.currentThread();    ThreadLocalMap map = getMap( t );    if ( map != null )
        {
            ThreadLocalMap.Entry e = map.getEntry( this );
            if ( e != null )
            {
                @SuppressWarnings( "unchecked" )
                T result = (T) e.value;            return(result);
            }
        }
        return(setInitialValue() );
    }
    
    
    

    先获取到当前线程的ThreadLocalMap,然后通过将自己作为key取得内部的实际数据

    如果希望及时回收对象,我们应该使用ThreadLocal.remove()方法将这个变量移除,否则如果将一些大的对象设置到 ThreadLocal中,没有及时回收,会造成内存泄漏的可能。

    无锁

    锁分为乐观锁悲观锁,而无锁就是一种乐观的策略,它是使用一种叫比较并交换(CAS,Compare And Swap)的技术来鉴别线程冲突,一旦检测到冲突发生,就重试当前操作直到没有冲突为止。

    比较并交换

    CAS的算法过程是:包含三个参数 CAS(V,E,N),其中V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V值设置为N值。最后返回当前V的真实值。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其他均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

    线程安全整数(AtomicInteger)

    AtomicInteger是在 JDK并发包中的atomic 中的,可以把它看作一个整数,与Integer不同的是,它是可变的,并且是线程安全的。对其进行修改等任何操作都是用 CAS 指令进行的。下面是AtomicInteger 的常用方法:

    public final int get()                                  /* 取得当前值 */
    public final void set( int newValue )                   /* 设置当前值 */
    public final int getAndSet( int newValue )              /* 设置新值,并返回旧值 */
    public final boolean compareAndSet( int expect, int u ) /* 如果当前值为expect,则设置为u */
    public final int getAndIncrement()                      /* 当前值加1,返回旧值 */
    public final int getAndDecrement()                      /* 当前值减1,返回旧值 */
    public final int getAndAdd( int delta )                 /* 当前值增加delta。返回旧值 */
    public final int incrementAndSet()                      /* 当前值加1,返回新值 */
    public final int decrementAndSet()                      /* 当前值减1,返回新值 */
    public final int addAndGet( int delta )                 /* 当前值增加delta,返回新 */
    

    就内部实现上来说,AtomicInteger中保存了一个核心字段

    private volatile int value;
    

    使用示例

    image.png

    可以看出,在多线程的情况下,AtomicInteger是保证线程安全的。

    无锁的对象引用(AtomicReference)

    AtomicReference 和AtomicInteger非常相似,不同之处就在于AtomicInteger是对整数的封装,而AtomicReference是对普通对象的引用,也就是它可以保证你在修改对象引用时的线程安全性。

    通常情况下线程判断被修改对象是否可以正确写入的条件是对象的当前值和期望值是否一致是正确的。但是有一种特殊的情况就是:当你获取对象当前数据后,在准备修改被新值前,对象的值被其他线程连续修改了两次,最后一次修改为旧值,这个时候线程在不知情的情况下,又对这个数据重新赋值。下图说明为例:

    image.png

    带有时间戳的对象引用(AtomicStampedReference)

    AtomicReference无法解决上面的问题是因为,对象在修改成成中丢失了状态信息,因此我们只要能够记录对象在修改过程中的状态值,就可以很好的解决对象被反复修改的导致线程无法正确判断对象状态的问题。

    AtomicStampedReference它内部不仅维护了对象值,还维护了一个时间戳,当AtomicStampedReference被修改的时候,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedReference设置对象值时,对象值及时间戳都必须满足期望值,写入才会成功,因此,即使对象之被反复读写,写回原值,只要时间戳发生变化,就不能正确写入。

    相关文章

      网友评论

          本文标题:Oh!老伙计,提高自己的并发技能,先从锁优化开始吧

          本文链接:https://www.haomeiwen.com/subject/fundyktx.html