JDK高并发——锁的优化

提高锁性能

减少锁持有时间

在锁竞争过程中,单个线程对锁的持有时间和系统性能有直接的关系。如果线程持有锁的时间越长,相对的,锁的竞争程度越激烈。

java 复制代码
public sychronized void syncMethod(){
  othercode1();
  mutexMethod();
  othercode2();
}

假设方法中只有mutexMethod需要同步控制,则在并发量大的时候,使用上面的同步方法,则会导致等待线程的大量增加。

通过优化减少线程持有锁的时间,提高系统的吞吐量。

改进方法:

java 复制代码
public void syncMethod2(){
 othercode1();
 sychronized(this){
  mutexMethod();
}
othercode2();
}

通过改进方法,只对mutexMethod方法做同步,锁占用的时间相对较短,因此能有更高的并行度。

减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

减小锁粒度

对于HashMap来说,最重要的两个方法就是get()和put()。一种最自然的想法就是,对整个HashMap加锁从而得到一个线程安全的对象,但是,这样做加锁的粒度过大。

ConcurrentHashMap内部进一步细分了若干个小的HashMap,称为segment,在默认情况下,一个ConcurrentHashMap类可以细分为16个段。

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

但是,减小锁粒度会带来一个新的问题,即当系统需要取得全局锁时,消耗的资源会比较多。例如,concurrentHashMap在put()时很好的分离了锁,但是试图访问concurrentHashMap的全局信息时,如size()就需要同时取得所有段的锁才能顺利实施。

java 复制代码
for(int i=0;i<segments.length;i++){
 segments[i].lock();
}
for(int i=0;i<segments.length;i++){
 sum+=segments[i].count;
}
for(int i=0;i<segments.length;i++){
 segments[i].unlock();
}

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

用读写分离锁来替换独占锁

使用读写分离锁ReadWriteLock可以提高系统的性能,使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁是对系统功能点的分割。

在读多写少的场合使用读写锁可以有效提升系统的并发能力。

锁分离

将读写锁的思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是LinkedBlockingQueue的实现。

在LinkedBlockingQueue实现中,take()和put()分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都是对当前队列进行修改操作,但是 LinkedBlockingQueue是基于链表的,因此两个操作分别作用于队列的尾端和前端。

如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()方法和put()方法就不能真正的并发,在运行时,他们会彼此等待对方释放锁资源。在这种情况下,锁竞争会相对激烈,从而影响程序在高并发时的性能。

java 复制代码
  1.  private final ReentrantLock takeLock = new ReentrantLock(); 
    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition(); 

    /** Lock held by put, offer, etc */
  2.  private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();
  1. take方法需要持有takeLock
  2. put方法需要持有putLock

take()方法实现

java 复制代码
    public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        //不能有两个线程同时take
        takeLock.lockInterruptibly();
        try {
		   // 循环等待判断,防止出现虚假唤醒。
            while (count.get() == 0) {
            // 如果当前没有数据,则等待
                notEmpty.await();
            }
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
            // 取出数据后,容量仍然大于1,通知其他等待take的线程
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
        	// 通知等待put的线程,可以进行put操作。
            signalNotFull();
        return x;
    }

put()方法实现

java 复制代码
public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<E>(e);

        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        //获取putLock,同一时间只能有一个put操作。
        putLock.lockInterruptibly();
        try {
           // 当容量已满时,put线程阻塞
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
            //放入后,容量小于capacity,唤醒其他等待put的线程
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
        // 放入前,容量为空,现在放入了一个元素,可以唤醒等待take的线程
            signalNotEmpty();
    }

通过takeLock和putLock两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。

锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。但是,如果对同一个锁不停的进行请求、同步、释放,其本身也会消耗系统的宝贵资源,反而不利于性能的优化。

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

在开发过程中,也应该有意识的在合理的场合进行锁的粗化,尤其是在循环中请求锁时,如下:

java 复制代码
for(int i=0;i<n;i++){
 sychronized(lock){
  //do sth....
 }
}

可以优化成下面的结构:

java 复制代码
sychronized(lock){
for(int i=0;i<n;i++){
  //do sth....
 }
}

JVM锁优化

偏向锁

核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作。这样就节省了大量有关锁申请的操作。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的效果,因为连读多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,最有可能是每次都是不同的线程来请求相同的锁,这样偏向模式会失效,因此还不如不启用偏向锁。

轻量级锁

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

自旋锁

锁膨胀后,为了避免线程真实的在操作系统层面挂起,虚拟机还会做最后的努力------自旋锁,当前线程暂时无法获得锁,而且什么时候可以获得锁是一个未知数,系统会假设在不久的将来,线程就可以得到这把锁。因此,虚拟机会让当前线程做几个空循环,再经过若干次循环后,如果可以得到锁,那么就顺利进入临界区,如果还不能获得锁,将在操作系统层面挂起。

锁消除

锁消除是一种更彻底的优化,Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。

如下:

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

代码中,使用到Vector的局部变量,局部变量是在线程方法栈上分配,属于线程私有的数据,因此不可能存在其他线程访问。在这种情况下,Vector内部所有加锁的同步都是没有必要的,如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

相关推荐
慧都小妮子1 分钟前
Spire.PDF for .NET【页面设置】演示:打开 PDF 时自动显示书签或缩略图
java·pdf·.net
m51275 分钟前
LinuxC语言
java·服务器·前端
烦躁的大鼻嘎6 分钟前
模拟算法实例讲解:从理论到实践的编程之旅
数据结构·c++·算法·leetcode
IU宝10 分钟前
C/C++内存管理
java·c语言·c++
瓜牛_gn10 分钟前
依赖注入注解
java·后端·spring
hakesashou11 分钟前
Python中常用的函数介绍
java·网络·python
佚先森20 分钟前
2024ARM网络验证 支持一键云注入引流弹窗注册机 一键脱壳APP加固搭建程序源码及教程
java·html
C++忠实粉丝23 分钟前
计算机网络socket编程(4)_TCP socket API 详解
网络·数据结构·c++·网络协议·tcp/ip·计算机网络·算法
古月居GYH34 分钟前
在C++上实现反射用法
java·开发语言·c++
用户37791362947551 小时前
【循环神经网络】只会Python,也能让AI写出周杰伦风格的歌词
人工智能·算法