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

简介: 本文主要介绍 Java并行的入门

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


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



锁粗化


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


错误示例:


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


优化后:


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


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


错误示例:


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


优化后:


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


JVM进行的锁优化



偏向锁


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


但是在锁竞争比较激烈的场合,效果不佳,因为在竞争激烈的场合,最有可能的情况就是每次都是不同的线程来请求,这样偏向模式会失效,因此还不如不启用偏向锁。可以通过 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根笔给他们填写,人手一根,那么填写的速度也将大大增加。



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



为每一个线程分配不同的对象,需要在应用层面保证 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;


使用示例:



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


无锁的对象引用(AtomicReference)


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


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



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


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


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


目录
相关文章
|
4月前
|
存储 NoSQL Redis
单线程模型想象不到的高并发能力、多路复用是效率杠杆
单线程模型想象不到的高并发能力、多路复用是效率杠杆
|
2月前
|
缓存 编译器 数据处理
【C/C++ 性能优化】循环展开在C++中的艺术:提升性能的策略与实践
【C/C++ 性能优化】循环展开在C++中的艺术:提升性能的策略与实践
61 0
|
29天前
|
缓存 安全 Java
Java并发编程学习9-并发基础演练
【4月更文挑战第9天】本篇演示了如何通过前面博文学到的并发基础构建模块,来逐步构建一个 “高效且可伸缩”的结果缓存
26 1
Java并发编程学习9-并发基础演练
|
2月前
|
存储 缓存 安全
【C/C++ 项目优化实战】 分享几种基础且高效的策略优化和提升代码性能
【C/C++ 项目优化实战】 分享几种基础且高效的策略优化和提升代码性能
71 0
|
2月前
|
监控 Java 编译器
Go语言内存与并发性能综合优化策略
【2月更文挑战第11天】Go语言以其高效的并发处理能力和简洁的内存管理机制成为了现代软件开发中的热门选择。然而,在实际应用中,如何综合优化Go程序的内存使用和并发性能,仍然是一个值得探讨的话题。本文将深入探讨Go语言内存与并发性能的综合优化策略,包括内存布局优化、并发模式设计、资源池化以及性能监控与分析等方面,旨在帮助开发者全面提升Go程序的整体性能。
|
9月前
|
程序员 开发工具
衡量程序员能力最好的方式
衡量程序员能力最好的方式
66 1
|
10月前
|
消息中间件 缓存 NoSQL
面试官:如何设计一个高并发系统?我:就这?
说实话,如果面试官问你这个题目,那么你必须要使出全身吃奶劲了。为啥?因为你没看到现在很多公司招聘的D里都是说啥,有高并发就经验者优先。 如果你确实有真才实学,在互联网公司里干过高并发系统,那你确实拿 offer基本如探囊取物,没啥问题。面试官也绝对不会这样来问你,否则他就是蠢。
|
并行计算 安全 算法
Oh!老伙计,提高自己的并发技能,先从锁优化开始吧
锁是最常用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序的性能下降。 对于单任务或者单线程的应用而言,其主要资源消耗都花在任务本身,它既不需要维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。对于多线程应用来说,系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。并行计算之所以能提高系统的性能,并不是因为它"少干活"了,而是因为并行计算可以更合理地进行任务调度,充分利用各个CPU资源。
|
JSON 缓存 JavaScript
提高系统吞吐量的一把利器:DeferredResult 到底有多强?
提高系统吞吐量的一把利器:DeferredResult 到底有多强?
|
Java 程序员 调度
成为高级程序员不得不了解的并发
到目前为止,你学到的都是顺序编程,顺序编程的概念就是某一时刻只有一个任务在执行,顺序编程固然能够解决很多问题,但是对于某种任务,如果能够并发的执行程序中重要的部分就显得尤为重要,同时也可以极大提高程序运行效率,享受并发为你带来的便利。但是,熟练掌握并发编程理论和技术,对于只会CRUD的你来说是一种和你刚学面向对象一样的一种飞跃。
67 0