Java并发设计中关于锁的优化

锁的优化,分为以下几个方面吧

  • 1、锁的使用方式
  • 2、JVM对锁的优化
  • 3、除了控制资源的访问外,另外一种方式是:通过增加资源来保证所有对象的线程安全。(ThreadLocal)
  • 4、无锁, 就是不使用锁来达到并发控制的目的。(CAS算法)

一、提升锁性能的建议

这些操作,是使用锁的一些建议,属于使用锁的方法。

1.1、减少锁持有时间

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

这一点说的是,只在必要时进行同步,粗浅的说就是同步代码块(同步方法)里面的代码行数要尽可能的少。

举个例子:

public synchronized void syncMethod() {
	othercodel();
	mutextMethod();
  othercode2();
}

在syncMethod()方法中,假设只有mutextMethod()方法是有同步需要的,而othercode1()方法和othercode2()方法并不需要做同步控制。如果othercode1()和othercode2()是比较重量级的方法,则会花费较长的CPU时间。如果在并发量较大时,使用这种对整个方法做同步的方案,则会导致等待线程大量增加。因为一个线程,在进入该方法时获得内部锁,只有在所有任务都执行完后,才会释放锁。

一个优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量。也就是只对需要做同步控制的方法加锁。在改进的代码中只针对mutextMethod()方法做了同步,锁占用的时间相对较短,因此能有更高的并行度。

public void syncMethod2 (){
	othercodel();
	synchronized (this) {
		mutextMethod();
  }
  othercode2();
}

1.2、减小锁粒度

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

减小锁粒度这一方法,好像和上面减少锁持有时间说的一样,但减小锁粒度更多的是通过分割数据结构来实现的。

这种技术的典型场景就是ConcurrentHashMap类的实现。

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

对于ConcurrentHashMap类,它内部进一步细分了若干个小的HashMap,称之之为段(SEGMENT)。在默认情况下,一个ConcurrentHashMap类可以被细分为16个段。如果需要在ConcurrentHashMap类中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()方法操作。在多线程环境中,如果多个线程同时进行put()方法操作,只要被加入的表项不存放在同一个段中,线程间便可以做到真正的并行。由于默认有16个段,因此,如果够幸运的话,ConcurrentHashMap类可以接受16个线程同时插入(如果都插入不同的段中),从而大大提升其吞吐量。下面代码显示了put()方法操作的过程。第5~6行代码根据key获得对应段的序号。接着在第9行得到段,然后将数据插入给定的段中。

public V put(K key, V value) {
  Segment<K,V> s;
  if (value == null)
 			throw new NullPointerException ();
  int hash = hash(key);
  int j = (hash >>> segmentShift) & segmentMask;
  if ((s = (Segment<K,V>) UNSAFE.getObject        //nonvolatile; recheck
 			(segments, (j << SSHIFT) + SBASE)) = null)       // in ensureSegment.
  	s = ensureSegment(j);
  return s.put(key, hash, value, false);
}

但是,减小锁粒度会带来一个新的问题:即当系统需要取得全局锁时,其消耗的资源会比较多。

仍然以ConcurrentHashMap类为例,虽然其put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap类的全局信息时,就需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap类的size()方法,它将返回 ConcurrentHIashMap 类的有效表项的数量,即ConcurrentHashMap类的全部有效表项之和。要获取这个信息需要取得所有子段的锁,因此,其size()方法的部分代码如下:

sum = 0;
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();

可以看到在计算总数时,先要获得所有段的锁再求和。但是,ConcurentHashMap类的size()方法并不总是这样执行的,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方法。但不管怎么说,在高并发场合ConcurrentHashMap类的size()方法的性能依然要差于同步的HashMap。

因此,只有在类似于size()方法获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能在真正意义上提高系统的吞吐量。

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

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

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

在读多写少的场合,读写锁对系统性能是很有好处的。因为如果系统在读写数据时均只使用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能做到真正的并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性。因此,从理论上讲,在大部分情况下,可以允许多线程同时读,读写锁正是实现了这种功能。

读写锁的访问约束情况:

非阻塞阻塞
阻塞阻塞

如果在系统中,读操作的次数远远大于写操作的次数,则读写锁就可以发挥最大的功效,提升系统的性能。这里我给出一个稍微夸张点的案例例来说明读写锁对性能的帮助。

下面这段代码,如果使用读写锁,观察控制台的输出,实际上这段代码运行大约2秒多就能结束(写线程之间实际是串行的)。如果使用普通的重入锁代替读写锁,所有的读和写线程之间也都必须相互等待,因此整个程序的执行时间将长达20余秒。

public class ReadWriteLockDemo {
	private static Lock lock=new ReentrantLock();
	private static ReentrantReadWriteLock readWriteLock=new ReentrantReadWriteLock();
	private static Lock readLock = readWriteLock.readLock();
	private static Lock writeLock = readWriteLock.writeLock();
	private int value;
	
	public Object handleRead(Lock lock) throws InterruptedException{
		try{
			lock.lock();				//模拟读操作
			Thread.sleep(1000);			//读操作的耗时越多,读写锁的优势就越明显
			return value;				
		}finally{
		lock.unlock();
		}
	}

	public void handleWrite(Lock lock,int index) throws InterruptedException{
		try{
			lock.lock();				//模拟写操作
			Thread.sleep(1000);
			value=index;
		}finally{
		lock.unlock();
		}
	}
	
	public static void main(String[] args) {
		final ReadWriteLockDemo demo=new ReadWriteLockDemo();
		Runnable readRunnale=new Runnable() {
			@Override
			public void run() {
				try {
//					demo.handleRead(readLock);
					demo.handleRead(lock);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		};
		Runnable writeRunnale=new Runnable() {
			@Override
			public void run() {
				try {
//					demo.handleWrite(writeLock,new Random().nextInt());
					demo.handleWrite(lock,new Random().nextInt());
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		};
       
        for(int i=0;i<18;i++){
            new Thread(readRunnale).start();
        }
        
        for(int i=18;i<20;i++){
            new Thread(writeRunnale).start();
        }	
	}
}

1.4、锁分离

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

一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现,在LinkedBlockingQueue的实现中,take()函数和 put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。

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

因此,在JDK的实现中,并没有采用这样的方式,取而代之的是用两把不同的锁分离了take()方法和put()方法的操作。take()方法使用的是takeLock,put()方法使用的是putLock,因此,take()方法和put()方法就此相互独立,它们之间不存在锁竞争关系,只需要在take()方法和take()方法间、put()方法和 put()方法间分别对takeLock和putLock进行竞争。从而,削弱了锁竞争的可能性。也实现了取数据与读数据的分离。

下面是两个方法的源码:

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock<();//take()方法需要持有takeLock
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition ();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();//put()方法需要持有 putLock
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition ();
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) {
          // 等待put()方法操作的通知
            notEmpty.await();
        }
      	// 取得第一个数据
        x = dequeue();
      	// 数量减1,原子操作,因为会和 put()函数同时访问count。注意:变量c是count减1前的值
        c = count.getAndDecrement();
        if (c > 1)
          	// 通知其他take()方法操作
            notEmpty.signal();  
    } finally {
      	// 释放锁
        takeLock.unlock();
    }
    if (c == capacity)
      	// 通知put()方法操作,已有空余空间
        signalNotFull();
    return x;
}
    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.lockInterruptibly();
        try {
          	// 如果队列满了,等待
            while (count.get() == capacity) {
                notFull.await();
            }
          	// 插入数据
            enqueue(node);
          	// 更新总数,变量c是count加1前的值
            c = count.getAndIncrement();
            if (c + 1 < capacity)
              	// 有足够的空间,通知其他操作put的线程
                notFull.signal();
        } finally {
          	// 释放锁
            putLock.unlock();
        }
        if (c == 0)
          	// 插入成功后,通知take()方法取数据
            signalNotEmpty();
    }

1.5、锁粗化

性能优化就是根据运行时的真实情况对各个资源点进行行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同,因此要根据实际情况进行权衡。

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

为此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫作锁的粗化。虚拟机虽然有这样的优化,但是我们在使用锁的时候也要有意识的去调整这种情况。看下面的例子:

一个循环内请求锁,在这种情况下,意味着每次循环都有申请锁和释放锁的操作。但在这种情况下,显然是没有必要的。

for (int i = 0; i < size; i++) {
  	synchronized (lock) {
    }
}

所以,一种更加合理的做法应该是在外层只请求一次锁:

synchronized (lock) {
  	for (int i = 0; i < size; i++) {
		}
}

二、Java虚拟机对锁优化所做的努力

作为一款共用平台,JDK本身也为并发程序的性能绞尽脑汁。在JDK内部也想尽一切办法提供并发时的系统吞吐量。简单介绍几种JDK内部的"锁"优化策略。

2.1、锁偏向

锁偏向是一种针对加锁操作的优化手段。

它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。

这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳。因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。这样偏向模式会失效,因此还不如不启用偏向锁。使用Java虚拟机参数-XX:+UseBiasedLocking可以开启偏向锁。

2.2、轻量级锁

如果偏向锁失败,那么虚拟机并不会立即挂起线程,它还会使用一种称为轻量级锁的优化手段。

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

2.3、自旋锁

锁膨胀后,为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力——自旋锁。当前线程暂时无法获得锁,而且什么时候可以获得锁是一个未知数,也许在几个CPU时钟周期后就可以得到锁。如果这样,简单粗暴地挂起线程可能是一种得不偿失的操作。系统会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能获得锁,才会真的将线程在操作系统层面挂起。

那什么是自旋锁呢?在代码中的表现形式是怎样的?

其实就是一个空循环,重点在于循环的条件,如果满足条件,就一直进入循环,在循环体什么也不做,如果不满足条件就跳出循环,继续执行。一般情况下,自旋锁都用CAS来作为条件,CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。来看下unsafe.getAndAddInt(this, valueOffset, 1);源码

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
}

解释:调用这个方法是,传了是三个参数:this就是当前对象赋值给var1,valueoffset是内存地址偏移值赋值给var2,1就是要加的值赋值给var4,通过var1和var2获取当前对象在内存中的值赋值给var5,然后通过compareAndSwapInt (CAS比较并交换,这是一个用native修饰的方法)这个方法来判断当前对象的内存地址偏移值中对应的值如果还是var5 (我期望的值),就执行加1。

这里就运用到了自旋锁。

自旋锁的优点:

  • 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快。

  • 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

自旋锁存在的问题:

  • 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。

  • 自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

2.4、锁消除

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

这里可能会产生疑问,如果不存在竟争,为什么程序员还要加上锁呢?这是因为在Java软件开发过程中,我们必然会使用一些JDK的内置API,比如StringBuffer、Vector等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用Vector。而Vector内部使用了synchronized请求锁,比如下面的代码:

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

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

逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。

使用-XX:+EliminateLocks参数可以打开锁消除。

三、ThreadLocal

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。

比如,让100个人填写个人信息表,如果只有一支笔,那么大家就得挨个填,对于管理人员来
说,必须保证大家不会去哄抢这仅存的一支笔,否则,谁也填不完。从另外一个角度出发,我们可以准备100支笔,人手一支,那么所有人很快就能完成表格的填写工作。

如果说锁使用的是第一种思路,那么ThreadLocal使用的就是是第二种思路。

篇幅太长,请参考:人手一支笔:ThreadLocal

四、无锁

换一种角度思考,一定要加锁吗?提升锁的性能,本质是在提升程序运行的性能。假如不用锁(不去锁住资源),还有其他方式来实现同样的功能吗?并且不用锁,也不会产生有关锁的问题,比如:死锁。

而这种方式,自然而然被称为“无锁”,无锁的策略使用一种叫作比较交换(CAS,CompareAndSwap)的技术来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。

先补充一下概念吧。思考上述两种对于并发控制的方式。一种加锁,一种无锁。对比人的性格而言,分为乐天派和悲观派。相对应起来,也就有了乐观锁与悲观锁。

对于并发控制而言,锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突,因此,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,则宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。

而无锁是一种乐观的策略,它会假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下持续执行。那遇到冲突怎么办呢?那就是使用刚刚提到的比较交换(CAS)。

相对于有锁的方法,使用无锁的方式编程更加考验一个程序员的耐心和智力。但是,无锁带来的好处也是显而易见的,第一,在高并发的情况下,它比有锁的程序拥有更好的性能。第二,它天生就是死锁免疫的。就凭借这两个优势,就值得我们冒险尝试使用无锁并发。

篇幅太长,请参考:无锁:CAS算法 与 原子类

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

悬浮海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值