该文章为面试精华版,如果是初学者,建议学习专栏:Java并发专栏

Java并发需要结合JVM以及操作系统的相关知识,建议先学习这两个部分:JVM专栏操作系统专栏

1. 类锁和对象锁的区别

对象锁

  • 对象锁有两种,第一种是在普通方法上加synchronize关键字,一种是使用synchronize同步代码块同步一个对象
//同步代码块
public void func() {
    synchronized (this) {
    // ...
    }
}

//同步一个方法
public synchronized void func () {
	// ...
}

作用都是同一个对象,如果不同线程去争抢同一个对象的头,就会陷入阻塞,如果是争抢不同对象的头,则不会阻塞

类锁

  • 类锁有两种,第一种是在静态方法上加synchronize关键字,一种是使用synchronize同步代码块同步类
//同步一个类
public void func() {
    synchronized (SynchronizedExample.class) {
    // ...
    }
}
public synchronized static void fun() {
	// ...
}

作用于整个类,如果是一个类不同的对象,调用这些方法的时候,会陷入阻塞

类锁是一种特殊的对象锁,类锁和对象锁互不干扰,也就是说一个线程访问同步静态方法,另一个线程访问同步普通方法,是不会阻塞的

public class Test {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        Solution solution = new Solution();
        executor.execute(new Runnable() {
            @Override
            public void run() {
                solution.fun2();
            }
        });
        executor.execute(new Runnable() {
            @Override
            public void run() {
                Solution.fun1();
            }
        });
        executor.shutdown();
    }

}


class Solution {

    public  synchronized static void fun1(){
        System.out.println("fun1...");
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public  synchronized void fun2(){
        System.out.println("fun2...");
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

2. synchronize底层实现原理

每个对象出生就有一个对象头,而Mark Word的中会包含锁信息和hashcode信息

synchronize获取锁的过程其实是在Mark Word加入线程信息的过程

Monitor :每个Java对象天生自带了一把看不见的锁

3. 什么是锁重入?

就是如果一个线程获取了一个对象的锁,如果在这个时候,又尝试获取这个对象的锁,会直接进入:

class Solution {
    private void test(){
        synchronized (this){
            synchronized (this){
                //执行代码
            }
        }
    }
}

synchronize和ReentrantLock都是支持可重入的

4. 为什么会对synchronized嗤之以鼻

  • 早期版本中, synchronized属于重量级锁,依赖于Mutex Lock实现
  • 线程之间的切换需要从用户态转换到核心态,开销较大

4. 锁优化

  • synchronized有四种状态无锁、偏向锁、轻量级锁、重量级锁
  • 偏向锁不会使用CAS操作获取锁,直接让第一次获取对象头mark word线程访问,当有其他线程争用对象头时,会结束偏向,膨胀成轻量级锁
  • 轻量级锁会使用CAS操作来获取锁,当获取锁失败的时候,会采用自旋的方式,如果多次失败,会锁升级为重量级锁
  • 同时优化了自旋使用自适应自旋,自旋的次数不固定,会根据上次自旋的次数来决定这次自旋的次数来决定这次自旋的次数
  • 重量级锁和轻量级锁的差异在于,线程获取不到锁,就会陷入阻塞,切换锁的性能十分差,频繁的切换锁就会造成大量的性能优化
  • 锁升级策略主要应对一系列操作频繁的获取锁和释放锁,将扩大加锁的范围,一次加锁就可以完成操作
  • 锁消除会根据逃逸分析,当共享变量并不会被其他线程访问,则可以消除加锁的过程

5. 自旋锁和自适应自旋锁

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用***享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

6. 锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作(JDK1.6之后变成StringBuider):

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

每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString()方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

7. 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

8. 锁膨胀和锁降级

所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

9. 偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态

10. 轻量级锁

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为001,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。

当竞争线程尝试占用轻量级锁失败多次之后(使用自旋)轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。

10. 重量级锁

重量级锁的加锁、解锁过程和轻量级锁差不多,区别是:竞争失败后,线程阻塞,释放锁后,唤醒阻塞的线程,不使用自旋锁,不会那么消耗CPU,所以重量级锁适合用在同步块执行时间长的情况下。

11. ReentrantLock

  • ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。
  • ReenTrantLock的实现是一种自旋锁, 通过循环调用CAS操作来实现加锁。
  • 它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
  • 支持公平锁,但是只是相对公平,并不能一定的保证公平,可以避免线程的饥饿现象,即长时间获取不到CPU的执行权,但是如果没有业务需求,不建议使用,因为其会降低性能
  • 必须手动的释放锁,一般使用try…catch…finally释放锁
  • 支持可重入

12. synchronize和ReentrantLock

  • Lock基于AQS实现,通过int类型状态CAS机制来维护锁的获取与释放
  • synchronized需要通过monitor,经历一个从用户态到内核态的转变过程,更加耗时
比较 synchronized Lock
锁的实现 是java内置关键字,在jvm层面 是个java类
能否判断状态 无法判断是否获取锁的状态 可以判断是否获取到锁
释放锁的方式 会自动释放锁 需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁
等待可中断 线程会一直等待下去 如果尝试获取不到锁,线程可以不用一直等待就结束
公平锁 不支持 支持公平锁
性能 新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等 基于AQS实现,性能和synchronize差不多

使用建议

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。