Java并发编程-锁的优化和注意事项

 在使用使用多线程的时候,要确保线程的安全,就需要用锁来确保安全性,但如果过度的使用锁,则可能导致如下种种问题:

死锁

   死锁就是两个或两个以上的线程被无限的阻塞,线程之间相互等待所需资源。这种情况可能发生在当两个线程尝试获取其它资源的锁,而每个线程又陷入无限等待其它资源锁的释放,除非一个用户进程被终止。

   就 JavaAPI 而言,线程死锁可能发生在一下情况。

     当两个线程相互调用 Thread.join ()

     当两个线程使用嵌套的同步块,一个线程占用了另外一个线程必需的锁,互相等待时被阻塞就有可能出现死锁。

 1、锁顺序死锁

   两个线程的操作是交替执行,并且需要对方的锁时,那么它们会发生死锁。

public class LeftRightDeadlock {
    private final Object left = new Object();
    private final Object right = new Object();
    public void leftRight() {
        synchronized (left) {
            synchronized (right) {
                doSomething();
            }
        }
    }
    public void rightLeft() {
        synchronized (right) {
            synchronized (left) {
                doSomethingElse();
            }
        }
    }
    void doSomething() {
    }
    void doSomethingElse() {
    }
}

    在LeftRightDeadlock中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。如果每个需要锁L和锁M的线程都以相同的顺序来获取L和M那么就不会发生死锁了。所以在实现的时候需要明确线程以固定的顺序来获取锁。

2、动态的锁顺序死锁

  有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。如看似一段很平常的代码,他将资金从一个帐号转至另外一个帐号,在开始转账之前,都首先获取了账户对象的锁,以确保通过原子方式更新两个账户的余额。

public static void transferMoney(Account fromAccount,
                                     Account toAccount,
                                     DollarAmount amount)
            throws InsufficientFundsException {
        synchronized (fromAccount) {
            synchronized (toAccount) {
                if (fromAccount.getBalance().compareTo(amount) < 0)
                    throw new InsufficientFundsException();
                else {
                    fromAccount.debit(amount);
                    toAccount.credit(amount);
                }
            }
        }
    }

  在transferMoney中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数顺序又取决于外部输入。如果两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁:

A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);

  如果执行时序不当,那么A可能获得myAccount的锁并等待yourAccount的锁,然而B此时持有yourAccount的锁,并正在等待myAccount的锁。

3、资源死锁

   正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。

死锁的避免与诊断

   尽量减少潜在的加锁交互数量。在编程的过程中,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。

   还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能来代替内置锁机制。如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次尝试,从而消除了死锁发生的条件,使程序恢复过来。

如果发生了死锁,可以通过Jstack,来分析。


尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿、活锁。

饥饿

  一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。

  解决饥饿的方案被称之为“公平性” – 即所有线程均能公平地获得运行机会。

 Java中导致饥饿的原因:

   ① 高优先级线程吞噬所有的低优先级线程的CPU时间。

   ② 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。

   ③ 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是被持续地获得唤醒。

在Java中实现公平性方案,需要:

   ① 使用锁,而不是同步块。

   ② 公平锁。

   ③ 注意性能方面。

详细学习文章 饥饿和公平:

   http://ifeve.com/starvation-and-fairness/#header

活锁

  任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。

  活锁的例子:

    单一实体的活锁

     例如线程从队列中拿出一个任务来执行,如果任务执行失败,那么将任务重新加入队列,继续执行。假设任务总是执行失败,或者某种依赖的条件总是不满足,那么线程一直在繁忙却没有任何结果。

协同导致的活锁

     生活中的典型例子: 两个人在窄路相遇,同时向一个方向避让,然后又向另一个方向避让,如此反复。

通信中也有类似的例子,多个用户共享信道(最简单的例子是大家都用对讲机),同一时刻只能有一方发送信息。发送信号的用户会进行冲突检测, 如果发生冲突,就选择避让,然后再发送。 假设避让算法不合理,就导致每次发送,都冲突,避让后再发送,还是冲突。

    计算机中的例子:两个线程发生了某些条件的碰撞后重新执行,那么如果再次尝试后依然发生了碰撞,长此下去就有可能发生活锁。

活锁的解决方法:

   解决协同活锁的一种方案是调整重试机制。

   比如引入一些随机性。例如如果检测到冲突,那么就暂停随机的一定时间进行重试。这回大大减少碰撞的可能性。 典型的例子是以太网的CSMA/CD检测机制。

   另外为了避免可能的死锁,适当加入一定的重试次数也是有效的解决办法。尽管这在业务上会引起一些复杂的逻辑处理。

比如约定重试机制避免再次冲突。 例如自动驾驶的防碰撞系统(假想的例子),可以根据序列号约定检测到相撞风险时,序列号小的飞机朝上飞, 序列号大的飞机朝下飞。

一旦用到锁,就说明这是阻塞式的,所以在并发度上一般来说都会比无锁的情况低一点。所以能在使用锁的时候,利用锁的优化,从而能提高性能。

锁优化的思路和方法总结一下,有以下几种

减少锁持有时间

减小锁粒度

锁分离

锁粗化

锁消除

减少锁持有时间

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

  像上述代码这样,在进入方法前就要得到锁,其他线程就要在外面等待。

  这里优化的一点在于,要减少其他线程等待的时间,所以,只用在有线程安全要求的程序上加锁

 public void syncMethod(){  
        othercode1();  
        synchronized(this)
        {
            mutextMethod();  
        }
        othercode2(); 
    }

   将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。

  最典型的减小锁粒度的案例就是ConcurrentHashMap。

   HashMap的同步实现

      – Collections.synchronizedMap(Map<K,V> m)

       – 返回SynchronizedMap对象

public V get(Object key) {
     synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
   synchronized (mutex) {return m.put(key, value);}
}

  ConcurrentHashMap

     – 若干个Segment :Segment<K,V>[] segments

     – Segment中维护HashEntry<K,V>

     – put操作时

    • 先定位到Segment,锁定一个Segment,执行put

    在减小锁粒度后, ConcurrentHashMap允许若干个线程同时进入

锁分离

   最常见的锁分离就是读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能

   比如LinkedBlockingQueue

   blob.png

   从头部取出,从尾部放数据。

     http://www.dczou.com/viemall/314.html

锁粗化

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

举个例子:

public void demoMethod(){  
        synchronized(lock){   
            //do sth.  
        }  
        //做其他不需要的同步的工作,但能很快执行完毕  
        synchronized(lock){   
            //do sth.  
        } 
    }

这种情况,根据锁粗化的思想,应该合并

public void demoMethod(){  
        //整合成一次锁请求 
        synchronized(lock){   
            //do sth.   
            //做其他不需要的同步的工作,但能很快执行完毕  
        }
    }

  当然这是有前提的,前提就是中间的那些不需要同步的工作是很快执行完成的。

  再举一个极端的例子:

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

 在一个循环内不同得获得锁。虽然JDK内部会对这个代码做些优化,但是还不如直接写成

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

    当然如果有需求说,这样的循环太久,需要给其他线程不要等待太久,那只能写成上面那种。如果没有这样类似的需求,还是直接写成下面那种比较好。

锁消除

   锁消除是在编译器级别的事情。

   在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。

   也许你会觉得奇怪,既然有些对象不可能被多线程访问,那为什么要加锁呢?写代码时直接不加锁不就好了。

   但是有时,这些锁并不是程序员所写的,有的是JDK实现中就有锁的,比如Vector和StringBuffer这样的类,它们中的很多方法都是有锁的。当我们在一些不会有线程安全的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能。

比如:

public static void main(String args[]) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 2000000; i++) {
            createStringBuffer("JVM", "Diagnosis");
        }
        long bufferCost = System.currentTimeMillis() - start;
        System.out.println("craeteStringBuffer: " + bufferCost + " ms");
    }
 
    public static String createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }

 上述代码中的StringBuffer.append是一个同步操作,但是StringBuffer却是一个局部变量,并且方法也并没有把StringBuffer返回,所以不可能会有多线程去访问它。

    那么此时StringBuffer中的同步操作就是没有意义的。

    开启锁消除是在JVM参数上设置的,当然需要在server模式下:

    -server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

    并且要开启逃逸分析。 逃逸分析的作用呢,就是看看变量是否有可能逃出作用域的范围。

    比如上述的StringBuffer,上述代码中craeteStringBuffer的返回是一个String,所以这个局部变量StringBuffer在其他地方都不会被使用。如果将craeteStringBuffer改成

public static StringBuffer craeteStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
    }

    那么这个 StringBuffer被返回后,是有可能被任何其他地方所使用的(譬如被主函数将返回结果put进map啊等等)。那么JVM的逃逸分析可以分析出,这个局部变量 StringBuffer逃出了它的作用域。

    所以基于逃逸分析,JVM可以判断,如果这个局部变量StringBuffer并没有逃出它的作用域,那么可以确定这个StringBuffer并不会被多线程所访问,那么就可以把这些多余的锁给去掉来提高性能。

   当JVM参数为:

     -server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

   输出:

    craeteStringBuffer: 302 ms

   JVM参数为:

     -server -XX:+DoEscapeAnalysis -XX:-EliminateLocks

   输出:

     craeteStringBuffer: 660 ms

   显然,锁消除的效果还是很明显的。

2. 虚拟机内的锁优化

  首先要介绍下对象头,在JVM中,每个对象都有一个对象头。

  Mark Word,对象头的标记,32位(32位系统)。

  描述对象的hash、锁信息,垃圾回收标记,年龄

  还会保存指向锁记录的指针,指向monitor的指针,偏向锁线程ID等。

  简单来说,对象头就是要保存一些系统性的信息。

2.1 偏向锁

  所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程 。

  大部分情况是没有竞争的(某个同步块大多数情况都不会出现多线程同时竞争锁),所以可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时,会判断是否偏向锁指向我,那么该线程将不用再次获得锁,直接就可以进入同步块。

  偏向锁的实施就是将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark

  当其他线程请求相同的锁时,偏向模式结束

  JVM默认启用偏向锁 -XX:+UseBiasedLocking

   在竞争激烈的场合,偏向锁会增加系统负担(每次都要加一次是否偏向的判断)

   偏向锁的例子:

public class Test {
    public static List<Integer> numberList = new Vector<Integer>();
 
    public static void main(String[] args) throws InterruptedException {
        long begin = System.currentTimeMillis();
        int count = 0;
        int startnum = 0;
        while (count < 10000000) {
            numberList.add(startnum);
            startnum += 2;
            count++;
        }
        long end = System.currentTimeMillis();
        System.out.println(end - begin);
    }
 
}

 Vector是一个线程安全的类,内部使用了锁机制。每次add都会进行锁请求。上述代码只有main一个线程再反复add请求锁。

使用如下的JVM参数来设置偏向锁:

 -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

  BiasedLockingStartupDelay表示系统启动几秒钟后启用偏向锁。默认为4秒,原因在于,系统刚启动时,一般数据竞争是比较激烈的,此时启用偏向锁会降低性能。

   由于这里为了测试偏向锁的性能,所以把延迟偏向锁的时间设置为0。

   此时输出为9209

 下面关闭偏向锁:

 -XX:-UseBiasedLocking

输出为9627

   一般在无竞争时,启用偏向锁性能会提高5%左右。

2.2轻量级锁

   Java的多线程安全是基于Lock机制实现的,而Lock的性能往往不如人意。

   原因是,monitorenter与monitorexit这两个控制多线程同步的bytecode原语,是JVM依赖操作系统互斥(mutex)来实现的。

   互斥是一种会导致线程挂起,并在较短的时间内又需要重新调度回原线程的,较为消耗资源的操作。

   为了优化Java的Lock机制,从Java6开始引入了轻量级锁的概念。

   轻量级锁(Lightweight Locking)本意是为了减少多线程进入互斥的几率,并不是要替代互斥。

   它利用了CPU原语Compare-And-Swap(CAS,汇编指令CMPXCHG),尝试在进入互斥前,进行补救。

   如果偏向锁失败,那么系统会进行轻量级锁的操作。它存在的目的是尽可能不用动用操作系统层面的互斥,因为那个性能会比较差。因为JVM本身就是一个应用,所以希望在应用层面上就解决线程同步问题。

   总结一下就是轻量级锁是一种快速的锁定方法,在进入互斥之前,使用CAS操作来尝试加锁,尽量不要用操作系统层面的互斥,提高了性能。

那么当偏向锁失败时,轻量级锁的步骤:

1.将对象头的Mark指针保存到锁对象中(这里的对象指的就是锁住的对象,比如synchronized (this){},this就是这里的对象)。

lock->set_displaced_header(mark);

2.将对象头设置为指向锁的指针(在线程栈空间中)。

if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(),mark)) 
         {       
             TEVENT (slow_enter: release stacklock) ;       
             return ; 
         }

   lock位于线程栈中。所以判断一个线程是否持有这把锁,只要判断这个对象头指向的空间是否在这个线程栈的地址空间当中。

如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁),就是操作系统层面的同步方法。在没有锁竞争的情况,轻量级锁减少传统锁使用OS互斥量产生的性能损耗。在竞争非常激烈时(轻量级锁总是失败),轻量级锁会多做很多额外操作,导致性能下降。

2.3 自旋锁

   当竞争存在时,因为轻量级锁尝试失败,之后有可能会直接升级成重量级锁动用操作系统层面的互斥。也有可能再尝试一下自旋锁。

   如果线程可以很快获得锁,那么可以不在OS层挂起线程,让线程做几个空操作(自旋),并且不停地尝试拿到这个锁(类似tryLock),当然循环的次数是有限制的,当循环次数达到以后,仍然升级成重量级锁。所以在每个线程对于锁的持有时间很少时,   自旋锁能够尽量避免线程在OS层被挂起。

  JDK1.6中-XX:+UseSpinning开启

  JDK1.7中,去掉此参数,改为内置实现

  如果同步块很长,自旋失败,会降低系统性能。如果同步块很短,自旋成功,节省线程挂起切换时间,提升系统性能。

2.4 偏向锁,轻量级锁,自旋锁总结

   上述的锁不是Java语言层面的锁优化方法,是内置在JVM当中的。

   首先偏向锁是为了避免某个线程反复获得/释放同一把锁时的性能消耗,如果仍然是同个线程去获得这个锁,尝试偏向锁时会直接进入同步块,不需要再次获得锁。

   而轻量级锁和自旋锁都是为了避免直接调用操作系统层面的互斥操作,因为挂起线程是一个很耗资源的操作。

   为了尽量避免使用重量级锁(操作系统层面的互斥),首先会尝试轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争。但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环时都不断尝试获得锁。如果自旋锁也失败,那么只能升级成重量级锁。

   可见偏向锁,轻量级锁,自旋锁都是乐观锁。

 参考资料:

    http://www.importnew.com/21353.html

   <<偏向锁,轻量级锁,自旋锁,重量级锁的详细介绍>>

发表评论