java进阶3 -「锁」

1 synchronized底层原理

我们先通过反编译下面的代码来说明问题。

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

 

通过上面我们看出Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

 

我们再看一下同步方法的反编译结果

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

 

 

2 锁的状态 - 无锁/偏向锁/轻量级锁/重量级锁

我们知道synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的mutex lock来实现的。而操作系统实现线程之间的切换就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是synchronized效率低的原因。因此,这种依赖于操作系统mutex lock所实现的锁我们称为"重量级锁"。jdk对于synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。jdk1.6以后,为了减少获得锁和释放锁带来的性能消耗,引入了"轻量级锁"和"偏向锁"

 

锁的状态总共有四种:无锁状态偏向锁轻量级锁重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁(锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级). JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。

偏向锁(自旋)

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。我们知道,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能

轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

轻量级锁也是一种多线程优化,它与偏向锁的区别在于,轻量级锁是通过CAS来避免进入开销较大的互斥操作,而偏向锁是在无竞争场景下完全消除同步,连CAS也不执行

对比

JDk中采用轻量级锁和偏向锁等对Synchronized有一系列的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:

 

 

3 适应性自旋/锁粗化/锁消除

a. 适应性自旋:从轻量级锁获取的流程中我们知道当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

b. 锁粗化:锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("a");
        stringBuffer.append("b");
        stringBuffer.append("c");
    }
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

 c. 锁消除:锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:

public class SynchronizedTest02 {

    public static void main(String[] args) {
        SynchronizedTest02 test02 = new SynchronizedTest02();
        //启动预热
        for (int i = 0; i < 10000; i++) {
            i++;
        }
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            test02.append("abc", "def");
        }
        System.out.println("Time=" + (System.currentTimeMillis() - start));
    }

    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。

为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。

 

  

4 synchronized 和ReentrantLock区别

实现上,synchronized是jvm层面实现的,可以通过一些监控工具监控synchronized的锁定,而且在代码执行出现异常时jvm会自动释放锁定。但是ReentrantLock则不行,完全是通过jdk实现的,需要程序保证锁一定会释放,必须将unLock放在finally中

功能上,ReentrantLock有定时锁,中断锁,公平/非公平锁等额外功能

(1) 定时锁

a) lock(), 如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁

b) tryLock(), 如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false

c) tryLock(long timeout,TimeUnit unit), 如果获取了锁定立即返回true,如果别的线程正持有锁,等待给定的时间,在等待的过程中,获取锁定返回true,如果等待超时,返回false

(2) 中断锁

lockInterruptibly如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到获得锁定,或者当前线程被别的线程中断

(3) 非公平锁

 reenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 

性能上,在synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,( 因为采用的是cpu悲观锁,即线程获得是独占锁,独占锁意味着其他线程只能依靠阻塞来等待线程释放锁,而在cpu转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起cpu频繁的上下文切换导致效率很低。)但是自从java1.6以后,synchronized引入了偏向锁,轻量级锁, 锁消除,锁粗化,适应性自旋等,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。

另外,ReentrantLock提供了一个Condition类,用来实现分组唤醒一批线程,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。

 

 

5 synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

 

6 synchronized修饰方法和修饰代码块时有何不同

持有锁的对象不同:

  1. 修饰方法时:this引用的当前实例持有锁
  2. 修饰代码块时:要指定一个对象,该对象持有锁

 有一个类这样定义:

public class SynchronizedTest
{
    public synchronized void method1(){}
    public synchronized void method2(){}
    public static synchronized void method3(){}
    public static synchronized void method4(){}
}

那么,有SynchronizedTest的两个实例a和b,对于一下的几个选项有哪些能被一个以上的线程同时访问呢?

A. a.method1() vs. a.method2()   instance_a instance_a
B. a.method1() vs. b.method1()    instance_a instance_b
C. a.method3() vs. b.method4()  Synchronized.class Synchronized.class
D. a.method3() vs. b.method3()  Synchronized.class Synchronized.class
E. a.method1() vs. a.method3()   instance_a Synchronized.class

答案是什么呢?BE

 

 

7 异常时是否释放锁?同步是否具备继承性?

当一个线程执行的代码出现异常时,synchronized所持有的锁会自动释放,lock不会,所以建议在finally里面手动释放。同步不具有继承性(声明为synchronized的父类方法A,在子类中重写之后并不具备synchronized的特性)

 

 

8 是否了解自旋锁

自旋锁也经常用于线程(进程)之间的同步。一般来说,我们知道在线程A获得普通锁之后,如果再有线程B试图获得锁,那么这个线程将会挂起(阻塞)。那么我们来考虑这样一种case: 如果两个线程资源竞争不是特别激烈,而处理器阻塞一个线程引起的线程上下文切换的代价高于等待资源的代价的时候(锁的已保持者保持锁时间比较短),那么线程b可以不放弃cpu时间片,而是在"原地"盲等,直到锁的持有者释放了该锁。

自旋锁可能的问题:

1 过多占据cpu的时间: 如果锁的当前持有者长时间不释放该锁,那么等待过程将长时间地占据cpu时间片,导致cpu资源浪费。因此可以设定一个超时时间,过期等待着放弃cpu时间片

2 误用有死锁风险: 当一个线程连续两次获得自旋锁(如递归),那么第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(被自己),那么这时线程会一直等待自己释放该锁,而不能继续执行造成死锁。因此递归程序使用自旋锁应该遵循以下原则: 递归决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。

 

 

9 公平锁和非公平锁

 在Java的ReentrantLock构造函数中提供了两种锁:创建公平锁和非公平锁(默认)。代码如下:

public ReentrantLock() {
       sync = new NonfairSync();
}

 public ReentrantLock(boolean fair) {
       sync = fair ? new FairSync() : new NonfairSync();
}

在公平的锁上, 线程按照他们发出请求的顺序获取锁. 但在非公平锁上,则允许"插队": 当一个线程请求非公平锁时,如果在发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。非公平锁提倡插队行为,但是无法控制某个线程在合适的时候进行插队。

在公平的锁中,如果有另一个线程持有锁或者有其他线程在等待队列中等待这个锁,那么新发出的请求的线程将被放入到队列中。而非公平锁上,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。

非公平锁性能高于公平锁性能的原因:

在恢复一个被挂起的线程与该线程真正运行之间存在着严重的延迟。

假设线程A持有一个锁,并且线程B请求这个锁。由于锁被A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此B会再次尝试获取这个锁。与此同时,如果线程C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样就是一种双赢的局面:B获得锁的时刻并没有推迟,C更早的获得了锁,并且吞吐量也提高了。

当持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁。在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现。

  

 

10 可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

在下面的代码中,method1()method2()都被 synchronized 关键字修饰,method1()调用了method2()

public class SynchronizedDemo {
    public synchronized void method1() {
        System.out.println("方法1");
        method2();
    }

    public synchronized void method2() {
        System.out.println("方法2");
    }
}

由于 synchronized锁是可重入的,同一个线程在调用method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题。

 

 

11 可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

  

posted @ 2018-03-11 16:44  balfish  阅读(266)  评论(0编辑  收藏  举报