剑客
关注科技互联网

Java多线程笔记(三):关于锁

“锁”是较为常用的同步方法之一。在高并发环境下,激励的锁竞争会导致程序的性能下降。所以我们们将在这里讨论一些有关于锁问题以及一些注意事项。比如:

  • 避免死锁

  • 减少锁粒度

  • 锁分离

在多核时代,使用多线程可以明显地提高系统的性能。但事实上,使用多线程的方式会额外增加系统的开销。

对于单任务或者单线程的应用而言,其主要资源消耗都有花在任务本身。它既不需要号维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。但对于多线程应用来说,系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。

事实上,在单核CPU上,采用并行算法的效率一般要低于原始的串行算法的,其根本原因也在于此。因此,并行计算之所以能提高系统的性能,并不是因为它“少干活”了,而是因为并行计算可以更合理的进行任务调度,充分利用各个CPU资源。因此,合理的并发,才能将多核CPU的性能发挥到极致。

提高“锁”性能的几点建议

减少锁持有的时间

在锁的竞争中,单个线程对锁的持有时间与系统性能有直接的关系.应该尽可能的减少锁的占有时间,以减少线程之间互斥的可能.

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

同步整个方法,如果在并发量较大时,使用这种对整个方法做同步的方案.会导致等待线程大量增加.

public synchronized void method(){
    otherMethod();
    needSyncMethod();
    otherMethod();
}

优化方法之一是,只在必要时进行同步,这样就能明显的减少线程持有锁的时间,提高系统吞吐量.

public void method(){
    otherMethod();
    synchronized(this){
        needSyncMethod();
    }
    otherMethod();
}

减小锁粒度

减小锁的粒度也是一种削弱多线程锁竞争的有效手段.这种技术典型的应用场景就是ConcurrentHashMap类的实现。

对于HashMap来说,最重要的两个方法是put()和get()。一种最自然的想法就是对整个HashMap加锁,必然可以得到一个线程安全的对象。但这样做,就会导致加锁颗粒度太大。对于ConcurrentHashMap,它的内部进一步分了若干个小的HashMap,称之为(SEGMENT)。

默认情况下,一个ConcurrentHashMap进一步细分为16个段.如果增加表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被放在哪个段中,然后对该段加锁,完成put()操作.只要被加入的数据不存放在同一个表项,则多个线程的put()操作可以做到真正的并行.

由于默认16个段,所以ConcurrentHashMap最多可以同时接受16个线程同时插入(如果都不插入到不同的段中),从而大大提供其吞吐量。

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

读写分离锁替换独占锁

使用读写锁ReadWriteLock可以提高系统性能.如果说减少锁粒度是通过分割数据结构实现的,那么读写锁则是对系统功能点的分割。 在读多写少的场合使用读写锁可以有效替身系统的并发能力。
因为读操作本身是不会影响数据的完整性和一致性。所以讲道理应该可以允许多线同时读。

锁分离

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

take()和put()方法虽然都对队列进行了修改操作,但由于是链表,因此,两个操作分别作用于队列的前端和末尾,理论上两者并不冲突。使用独占锁,则要求在进行take和put操作时获取当前队列的独占锁,那么take和put酒不可能真正的并发,他们会彼此等待对方释放锁。在JDK的实现中,取而代之的是两把不同的锁,分离了take和put操作.削弱了竞争的可能性.实现类取数据和写数据的分离,实现了真正意义上成为并发操作。

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

    //take和put之间不存在锁竞争关系,只需要take和take之间,put和put之间进行竞争.
    // 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();

    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<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.getAndIncrement(); //更新总数,count是加(getAndIncrement先获取当前值,再给当前值加1,返回旧值)
                if (c + 1 < capacity) //如果旧值+1 小于 队列长度
                    notFull.signal(); //唤醒等待的写入线程.继续写入.
        } finally {
            putLock.unlock();  //释放锁
        }
        if (c == 0) //take操作拿完数据后就一直在notEmpty等待,这个时候的count为0,而当put操作后,成功后就可以唤醒take操作继续执行了.而当队列中count很多时,这一步是不需要执行的.
            signalNotEmpty(); //唤醒在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) { //如果队列为0,等待
                notEmpty.await();
            }
            x = dequeue();
            c = count.getAndDecrement(); //先取原值,再减1
            if (c > 1) //如果队列大于1,自己继续执行.
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity) //当长度等于设定的队列长度,就唤醒take操作.
            signalNotFull();
        return x;
    }

    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }
    private void signalNotFull() {
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {
            notFull.signal();
        } finally {
            putLock.unlock();
        }
    }

}

锁粗化

如果对一个锁不停地进行请求,同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能优化.

虚拟机在遇到需要一连串对同一把锁不断进行请求和释放操作的情况时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这就是锁的粗化.

public void demoMethod(){
    synchronized(lock){
        //doSth...
    }

    //其他不需要同步但很快完成的事情
    .....

    synchronized(lock){
        //doSth...
    }
}

整合如下:

public void demoMethod(){
    synchronized(lock){
        //doSth...

        //其他不需要同步但很快完成的事情
        .....
    }
}

在开发过程中,大家也应该有意识地在合理的场合进行锁的粗化,尤其当在循环内请求锁时。以下是一个循环内的请求锁的例子,在这种情况下,意味着每次循环都有申请锁和释放锁的操作。但在这种情况下,显然是没有必要的。

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

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

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

  }
}

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

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

锁偏向

偏向锁是一种针对加锁操作的优化手段,他的核心思想是:如果一个线程获得了锁,那么锁就进行偏向模式.当这个线程再次请求锁时,无需再做任何同步操作.这样就节省了大量操作锁的动作,从而提高程序性能.

因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果.因为极有可能连续多次是同一个线程请求相同的锁.而对于锁竞争激烈的程序,其效果不佳.

使用Java虚拟机参数:-XX:+UseBiasedLocking 可以开启偏向锁.

轻量级锁

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

自旋锁

锁膨胀后,虚拟机为了避免线程真实的在操作系统层面挂起,虚拟机还做了最后的努力就是自旋锁.如果一个线程暂时无法获得索,有可能在几个CPU时钟周期后就可以得到锁,

那么简单粗暴的挂起线程可能是得不偿失的操作.虚拟机会假设在很短时间内线程是可以获得锁的,所以会让线程自己空循环(这便是自旋的含义),如果尝试若干次后,可以得到锁,那么久可以顺利进入临界区,

如果还得不到,才会真实地讲线程在操作系统层面挂起.

锁消除

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

我们可能会问:如果不可能存在竞争,为什么程序员还要加上锁呢?

在Java软件开发过程中,我们必然会用上一些JDK的内置API,比如StringBuffer、Vector等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用Vector。而周所众知,Vector内部使用了synchronized请求锁,如下代码:

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

锁消除设计的一项关键技术是 逃逸分析
,就是观察某个变量是否会跳出某个作用域(比如对Vector的一些操作).在本例中,变量v显然没有逃出createString()函数之外。以次为基础,虚拟机才可以大胆将v内部逃逸出当前函数,也就是说v有可能被其他线程访问。如果是这样,虚拟机就不能消除v中的锁操作。

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

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址