二、Synchronized、volatile、Lock(ReentrantLock)相关

1、synchronized的原理?

synchronized 代码块是由一对儿 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现,而 synchronized 同步方法使用了ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。

现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。

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

当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。

如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁(可能会先进行自旋锁升级,如果失败再尝试重量级锁升级)。

我注意到有的观点认为 Java 不会进行锁降级。实际上据我所知,锁降级确实是会发生的,当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

2、Synchronized优化后的锁机制简单介绍一下,包括自旋锁、偏向锁、轻量级锁、重量级锁?

  • 自旋锁: 线程自旋说白了就是让CPU在做无用功,比如:可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。如果旋的时间过长会影响整体性能,时间过短又达不到延迟阻塞的目的。
  • 偏向锁: 偏向锁就是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。
  • 轻量级锁: 轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁竞争用的时候,偏向锁就会升级为轻量级锁;
  • 重量级锁: 重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

3、谈谈对Synchronized关键字涉及到的类锁,方法锁,重入锁的理解?

synchronized修饰静态方法获取的是类锁(类的字节码文件对象)。

synchronized修饰普通方法或代码块获取的是对象锁。这种机制确保了同一时刻对于每一个类实例,其所有声明为 synchronized 的成员函数中至多只有一个处于可执行状态,从而有效避免了类成员变量的访问冲突。

它俩是不冲突的,也就是说:获取了类锁的线程和获取了对象锁的线程是不冲突的!

public class Widget {
    // 锁住了
    public synchronized void doSomething() {
        ...
    }
}
public class LoggingWidget extends Widget {
    // 锁住了
    public synchronized void doSomething() {
        System.out.println(toString() + ": calling doSomething");
        super.doSomething();
    }
}

因为锁的持有者是“线程”,而不是“调用”。

线程A已经是有了LoggingWidget实例对象的锁了,当再需要的时候可以继续 “开锁” 进去的!这就是内置锁的可重入性。

4、wait、sleep的区别和notify运行过程。

wait、sleep的区别

最大的不同是在等待时 wait 会释放锁,而 sleep 一直持有锁。wait 通常被用于线程间交互,sleep 通常被用于暂停执行。

  • 首先,要记住这个差别,“sleep是Thread类的方法,wait是Object类中定义的方法”。尽管这两个方法都会影响线程的执行行为,但是本质上是有区别的。
  • Thread.sleep不会导致锁行为的改变,如果当前线程是拥有锁的,那么Thread.sleep不会让线程释放锁。如果能够帮助你记忆的话,可以简单认为和锁相关的方法都定义在Object类中,因此调用Thread.sleep是不会影响锁的相关行为。
  • Thread.sleep和Object.wait都会暂停当前的线程,对于CPU资源来说,不管是哪种方式暂停的线程,都表示它暂时不再需要CPU的执行时间。OS会将执行时间分配给其它线程。区别是,调用wait后,需要别的线程执行notify/notifyAll才能够重新获得CPU执行时间。
  • 线程的状态参考 Thread.State的定义。新创建的但是没有执行(还没有调用start())的线程处于“就绪”,或者说Thread.State.NEW状态。
  • Thread.State.BLOCKED(阻塞)表示线程正在获取锁时,因为锁不能获取到而被迫暂停执行下面的指令,一直等到这个锁被别的线程释放。BLOCKED状态下线程,OS调度机制需要决定下一个能够获取锁的线程是哪个,这种情况下,就是产生锁的争用,无论如何这都是很耗时的操作。
notify运行过程

当线程A(消费者)调用wait()方法后,线程A让出锁,自己进入等待状态,同时加入锁对象的等待队列。 线程B(生产者)获取锁后,调用notify方法通知锁对象的等待队列,使得线程A从等待队列进入阻塞队列。 线程A进入阻塞队列后,直至线程B释放锁后,线程A竞争得到锁继续从wait()方法后执行。

5、synchronized关键字和Lock的区别你知道吗?为什么Lock的性能好一些?

类别 synchronized Lock(底层实现主要是Volatile + CAS)
存在层次 Java的关键字,在jvm层面上 是一个类
锁的释放 1、已获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁。 在finally中必须释放锁,不然容易造成线程死锁。
锁的获取 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待。 分情况而定,Lock有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待
锁状态 无法判断 可以判断
锁类型 可重入 不可中断 非公平 可重入 可判断 可公平(两者皆可)
性能 少量同步 大量同步

Lock(ReentrantLock)的底层实现主要是Volatile + CAS(乐观锁),而synchronized是一种悲观锁,比较耗性能。但是在JDK1.6以后对Synchronized的锁机制进行了优化,加入了偏向锁、轻量级锁、自旋锁、重量级锁,在并发量不大的情况下,性能可能优于Lock机制。所以建议一般请求并发量不大的情况下使用synchronized关键字。

6、volatile原理。

在《Java并发编程:核心理论》一文中,我们已经提到可见性、有序性及原子性问题,通常情况下我们可以通过Synchronized关键字来解决这些个问题,不过如果对Synchonized原理有了解的话,应该知道Synchronized是一个较重量级的操作,对系统的性能有比较大的影响,所以如果有其他解决方案,我们通常都避免使用Synchronized来解决问题。

volatile关键字就是Java中提供的另一种解决可见性有序性问题的方案。对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可保证原子性的,如long和double类型变量,但是并不能保证i这种操作的原子性,因为本质上i是读、写两次操作。

volatile也是互斥同步的一种实现,不过它非常的轻量级。

volatile 的意义?
  • 防止CPU指令重排序

volatile有两条关键的语义:

  • 保证被volatile修饰的变量对所有线程都是可见的
  • 禁止进行指令重排序

要理解volatile关键字,我们得先从Java的线程模型开始说起。如图所示:

Java内存模型规定了所有字段(这些字段包括实例字段、静态字段等,不包括局部变量、方法参数等,因为这些是线程私有的,并不存在竞争)都存在主内存中,每个线程会 有自己的工作内存,工作内存里保存了线程所使用到的变量在主内存里的副本拷贝,线程对变量的操作只能在工作内存里进行,而不能直接读写主内存,当然不同内存之间也 无法直接访问对方的工作内存,也就是说主内存是线程传值的媒介。

我们来理解第一句话:保证被volatile修饰的变量对所有线程都是可见的

如何保证可见性?

volatile修饰的变量在工作内存修改后会被强制写回主内存,其他线程在使用时也会强制从主内存刷新,这样就保证了一致性。

关于“保证被volatile修饰的变量对所有线程都是可见的”,有种常见的错误理解:

  • 由于volatile修饰的变量在各个线程里都是一致的,所以基于volatile变量的运算在多线程并发的情况下是安全的。
  • 这句话的前半部分是对的,后半部分却错了,因此它忘记考虑变量的操作是否具有原子性这一问题。

举个例子:

private volatile int start = 0;
private void volatile Keyword() {
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                start++;
            }
        }
    };
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(runnable);
        thread.start();
    }
    Log.d(TAG, "start = " + start);
}

这段代码启动了10个线程,每次10次自增,按道理最终结果应该是100,但是结果并非如此。

为什么会这样?

仔细看一下start++,它其实并非一个原子操作,简单来看,它有两步:

  • 取出start的值,因为有volatile的修饰,这时候的值是正确的。
  • 自增,但是自增的时候,别的线程可能已经把start加大了,这种情况下就有可能把较小的start写回主内存中。 所以volatile只能保证可见性,在不符合以下场景下我们依然需要通过加锁来保证原子性:
    • 运算结果并不依赖变量当前的值,或者只有单一线程修改变量的值。(要么结果不依赖当前值,要么操作是原子性的,要么只要一个线程修改变量的值)
    • 变量不需要与其他状态变量共同参与不变约束 比方说我们会在线程里加个boolean变量,来判断线程是否停止,这种情况就非常适合使用volatile。

我们再来理解第二句话。

什么是指令重排序?

  • 指令重排序是指指令乱序执行,即在条件允许的情况下直接运行当前有能力立即执行的后续指令,避开为获取一条指令所需数据而造成的等待,通过乱序执行的技术提供执行效率。
  • 指令重排序会在被volatile修饰的变量的赋值操作前,添加一个内存屏障,指令重排序时不能把后面的指令重排序移到内存屏障之前的位置。

7、synchronized 和 volatile 关键字的作用和区别。

Volatile
  1. 保证了不同线程对这个变量进行操作时的可见性即一个线程修改了某个变量的值,这新值对其他线程来是立即可见的。
  2. 禁止进行指令重排序。
作用

volatile 本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其它线程被阻塞住。

区别
  1. volatile 仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  2. volatile 仅能实现变量的修改可见性,并不能保证原子性;synchronized 则可以保证变量的修改可见性和原子性。
  3. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
  4. volatile 标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

ReentrantLock实现的前提就是AbstractQueuedSynchronizer,简称AQS,是java.util.concurrent的核心,CountDownLatch、FutureTask、Semaphore、ReentrantLock等都有一个内部类是这个抽象类的子类。由于AQS是基于FIFO队列的实现,因此必然存在一个个节点,Node就是一个节点,Node有两种模式:共享模式和独占模式。ReentrantLock是基于AQS的,AQS是Java并发包中众多同步组件的构建基础,它通过一个int类型的状态变量state和一个FIFO队列来完成共享资源的获取,线程的排队等待等。AQS是个底层框架,采用模板方法模式,它定义了通用的较为复杂的逻辑骨架,比如线程的排队,阻塞,唤醒等,将这些复杂但实质通用的部分抽取出来,这些都是需要构建同步组件的使用者无需关心的,使用者仅需重写一些简单的指定的方法即可(其实就是对于共享变量state的一些简单的获取释放的操作)。AQS的子类一般只需要重写tryAcquire(int arg)和tryRelease(int arg)两个方法即可。

ReentrantLock的处理逻辑:

其内部定义了三个重要的静态内部类,Sync,NonFairSync,FairSync。Sync作为ReentrantLock中公用的同步组件,继承了AQS(要利用AQS复杂的顶层逻辑嘛,线程排队,阻塞,唤醒等等);NonFairSync和FairSync则都继承Sync,调用Sync的公用逻辑,然后再在各自内部完成自己特定的逻辑(公平或非公平)。

接着说下这两者的lock()方法实现原理:

NonFairSync(非公平可重入锁)
  1. 先获取state值,若为0,意味着此时没有线程获取到资源,CAS将其设置为1,设置成功则代表获取到排他锁了;
  2. 若state大于0,肯定有线程已经抢占到资源了,此时再去判断是否就是自己抢占的,是的话,state累加,返回true,重入成功,state的值即是线程重入的次数;
  3. 其他情况,则获取锁失败。
FairSync(公平可重入锁)

可以看到,公平锁的大致逻辑与非公平锁是一致的,不同的地方在于有了!hasQueuedPredecessors()这个判断逻辑,即便state为0,也不能贸然直接去获取,要先去看有没有还在排队的线程,若没有,才能尝试去获取,做后面的处理。反之,返回false,获取失败。

最后,说下ReentrantLock的tryRelease()方法实现原理:

  • 若state值为0,表示当前线程已完全释放干净,返回true,上层的AQS会意识到资源已空出。若不为0,则表示线程还占有资源,只不过将此次重入的资源的释放了而已,返回false。
  • ReentrantLock是一种可重入的,可实现公平性的互斥锁,它的设计基于AQS框架,可重入和公平性的实现逻辑都不难理解,每重入一次,state就加1,当然在释放的时候,也得一层一层释放。至于公平性,在尝试获取锁的时候多了一个判断:是否有比自己申请早的线程在同步队列中等待,若有,去等待;若没有,才允许去抢占。

9、ReentrantLock 、synchronized 和 volatile 比较?

synchronized是互斥同步的一种实现。

synchronized:当某个线程访问被synchronized标记的方法或代码块时,这个线程便获得了该对象的锁,其他线暂时无法访问这个方法,只有等待这个方法执行完毕或代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法代码块。

前面我们已经说了volatile关键字,这里我们举个例子来综合分析volatile与synchronized关键字的使用。举个例子:

public class Singleton {
    // volatile保证了:1 instance在多线程并发的可见性 2 禁止instance在操作是的指令重排序
    private volatile static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
        // 第一次判空,保证不必要的同步
        if (instance == null) {
            // synchronized对Singleton加全局锁,保证每次只要一个线程创建实例
            synchronized (Singleton.class) {
                // 第二次判空时为了在null的情况下创建实例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这是一个经典的DCL单例。它的字节码如下:

可以看到被synchronized同步的代码块,会在前后分别加上monitorenter和monitorexit,这两个字节码都需要指定加锁和解锁的对象。关于加锁和解锁的对象:

  • synchronized代码块: 同步代码块,作用范围是整个代码块,作用对象是调用这个代码块的对象。
  • synchronized方法: 同步方法,作用范围是整个方法,作用对象是调用这个方法的对象。
  • synchronized静态方法: 同步静态方法,作用范围是整个静态方法,作用对象是调用这个类的所有对象。
  • synchronized(this): 作用范围是该对象中所有被synchronized标记的变量、方法或代码块,作用对象是对象本身。
  • synchronized(ClassName.class): 作用范围是静态的方法或者静态变量,作用对象是Class对象。

synchronized(this)添加的是对象锁,synchronized(ClassName.class)添加的是类锁,它们的区别如下:

  • 对象锁:Java的所有对象都含有1个互斥锁,这个锁由JVM自动获取和释放。线程进入synchronized方法的时候获取该对象的锁,当然如果已经有线程获取了这个对象的锁那么当前线程会等待;synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁。这里也体现了用synchronized来加锁的好处,方法抛异常的时候,锁仍然可以由JVM来自动释放。
  • 类锁:对象锁是用来控制实例方法之间的同步,类锁是来控制静态方法(或静态变量互斥体)之间的同步。其实类锁只是一个概念上的东西,并不是真实存在的,它只用来帮助我们理解锁定实例方法和静态方法的区别的。我们都知道,Java类可能会有很多个对象,但是只有1个Class对象,也就说类的不同实例之间共享该类的Class对象。Class对象其实也仅仅是1个Java对象,只不过有点特殊而已。由于每个Java对象都有个互斥锁,而类的静态方法是需要Class对象。所以所谓类锁,不过是Class对象的锁而已。获取类的Class对象有好几种,最简单的就是MyClass.class的方式。类锁和对象锁不是同一个东西,一个是类的Class对象的锁,一个是类的实例的锁。也就是说:一个线程访问静态sychronized的时候,允许另一个线程访问对象的实例synchronized方法。反过来也是成立的,为他们需要的锁是不同的。