【并发】volatile、synchronized、Reentrantlock

86 阅读9分钟

面试题开路

前置知识

1. Java内存模型

image.png

每个线程都有自己的本地内存空间(java栈中的帧)。

  1. 线程执行时,先把变量从内存读到线程自己的本地内存空间,然对变量进行操作;
  2. 对该变量操作完成后,在某个时间再把变量刷新回主内存;

锁提供的两种特性:互斥性(mutual exclusion) 和可见性(visibility)

  1. 互斥性(mutual exclusion):一次只允许一个线程持有某个特定的锁,访问/使用共享数据;
  2. 可见性(visibility):一个线程修改了变量,其他线程可以立即知道; 实现可见性的方法:volatile,synchronized,final(一旦初始化完成其他线程就可见);

2.线程与进程

程序>线程>进程

  • 线程是程序执行流的最小单位,而进程是系统进行资源分配和调度的一个独立单位。

Thread的几个重要方法

  • start():开始执行该线程;
  • stop():强制结束该线程执行;
  • join():等待该线程结束。
  • sleep():进入等待。
  • run():直接执行线程的run()方法。线程调用start()方法时也会运行run()方法,区别是一个线程调度运行run()方法,一个直接调用线程中的run()方法!!

wait()和notify()

  • wait()与notify()是Object的方法,不是Thread的方法!! wait()与notify()会配合使用,wait()表示线程挂起,notify()表示线程恢复; wait()与sleep()的区别:简单来说,wait()会释放对象锁,而sleep()不会释放对象锁;

线程5大状态

image.png

  • 新建状态:新建线程对象,调用start()方法之前;
  • 就绪状态:调用start()方法之后线程就进入就绪状态,但是并不是说只要调用start()方法线程就马上变为当前线程,在变为当前线程之前都是就绪状态。值得一提的是,线程在睡眠和挂起中恢复的时候也会进入就绪状态哦。
  • 运行状态:线程被设置为当前线程,开始执行run()方法;
  • 阻塞状态:线程被暂停,比如调用sleep()方法后线程就进入阻塞状态;
  • 死亡状态:线程执行结束;

锁类型

  • 可重入锁:在执行对象中所有同步方法不用再次获得锁;

  • 可中断锁:在等待获取锁过程中可中断;

  • 公平锁: 按等待锁的时间进行获取(等待时间长的具有优先获取锁权利);

  • 读写锁:对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写;

  • 悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。

  • 非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。

  • 可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。

  • 独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

附属信息:

  • 原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源;
  • 可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,会对应执行 lock、unlock原子操作,保证可见性;
  • 有序性:程序执行顺序按代码的先后顺序执行。

先来第一波关键词问题

1. volatile、synchronized的区别?

  • volatile主要是保证内存的可见性,每次使用要去主内存读最新值。synchronized主要是解决多个线程访问资源的同步性;
  • volatile作用于变量,synchronized作用于代码块或者方法;
  • volatile仅可以保证数据的可见性,不能保证数据的原子性。synchronized可以保证数据的可见性和原子性。
  • volatile不会造成线程的阻塞,synchronized会造成线程的阻塞。

2. synchronized和Lock的区别?

Lock是显示锁,需要手动开启和关闭。synchronized是隐士锁,可以自动释放锁。 Lock是一个接口,是JDK实现的。synchronized是一个关键字,是依赖JVM实现的。 Lock是可中断锁,synchronized是不可中断锁,需要线程执行完才能释放锁。 发生异常时,Lock不会主动释放占有的锁,必须通过unlock进行手动释放,因此可能引发死锁。synchronized在发生异常时会自动释放占有的锁,不会出现死锁的情况。 Lock可以判断锁的状态,synchronized不可以判断锁的状态。 Lock实现锁的类型是可重入锁、公平锁。synchronized实现锁的类型是可重入锁,非公平锁。 Lock适用于大量同步代码块的场景,synchronized适用于少量同步代码块的场景。

3. synchronized实现原理?

进入synchronized块就是把在synchronized块内使用到的变量从线程的本地内存中擦除,这样在synchronized块中再次使用到该变量就不能从本地内存中获取了,需要从主内存中获取,解决了内存不可见问题。

这个问题也是面试比较高频的一个问题,也是比较难理解的,理解synchronized需要一定的Java虚拟机的知识。

在jdk1.6之前,synchronized被称为重量级锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。下面先介绍jdk1.6之前的synchronized原理。

重量级锁的底部实现原理:Monitor 在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的。

Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter和 monitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。

之前提到过一个常见面试题,为什么wait()、notify()等方法要在同步方法或同步代码块中来执行呢? 因为wait()、notify()方法需要借助ObjectMonitor对象内部方法来完成。

因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。

jDK1.6对synchronized做了哪些优化? 锁的升级

在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,如下图所示。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级。这种只能升级不能降级的策略是为了提高获得锁和释放锁的效率。

image.png 偏向锁

常见面试题:偏向锁的原理(或偏向锁的获取流程)、偏向锁的好处是什么(获取偏向锁的目的是什么)

  • 偏向锁原理:(一句话简单总结)使用CAS操作将当前线程的ID记录到对象的Mark Word中。
  • 偏向锁的好处:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁

偏向锁的获取流程:

  1. 检查对象头中Mark Word是否为可偏向状态,如果不是则直接升级为轻量级锁。
  2. 如果是,判断Mark Work中的线程ID是否指向当前线程,如果是,则执行同步代码块。
  3. 如果不是,则进行CAS操作竞争锁,如果竞争到锁,则将Mark Work中的线程ID设为当前线程ID,执行同步代码块。
  4. 如果竞争失败,升级为轻量级锁。

偏向锁的获取流程如下图: image.png

轻量级锁 引入轻量级锁的目的:在多线程交替执行同步代码块时(未发生竞争),避免使用互斥量(重量锁)带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。

自旋锁 什么是自旋锁:当线程A已经获得锁时,线程B再来竞争锁,线程B不会直接被阻塞,而是在原地循环等待,当线程A释放锁后,线程B可以马上获得锁。

引入自旋锁的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。

自适应自旋锁:JDK1.6引入了自适应自旋锁,自适应自旋锁的自旋次数不在固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于某个锁对象,刚刚有线程自旋等待成功获取到锁,那么虚拟机将认为这次自旋等待的成功率也很高,会允许线程自旋等待的时间更长一些。如果对于某个锁对象,线程自旋等待很少成功获取到锁,那么虚拟机将会减少线程自旋等待的时间。

偏向锁、轻量级锁、重量级锁对比

image.png

4. synchronized的使用

修饰方法、修饰方法块。

class syncTest implements Runnable {
     private static int i = 0;   //共享资源
     private synchronized void add() {
         i++;
     }
     @Override
     public void run() {
         for (int j = 0; j < 10000; j++) {
             add();
         }
     }
     public static void main(String[] args) throws Exception {
         syncTest syncTest = new syncTest();
         Thread t1 = new Thread(syncTest);
         Thread t2 = new Thread(syncTest);
         t1.start();
         t2.start();
         t1.join();
         t2.join();
         System.out.println(i);
     }
 }

经典多线程操作i++例子,这段代码本意是想得到答案10000+10000=20000,但实际值得到:18634

add()方法虽然也用synchronized关键字修饰了,但因为两次new syncTest()操作建立的是两个不同的对象。存在两个不同的对象锁,线程t1和t2使用的是不同的对象锁,并不能保证线程安全。

应该如何解决呢?

private static synchronized void add() {
       i++;
}

这好像就是单例模型啊,静态方法类的意思。 每次创建的实例对象都是不同的,而类对象却只有一个,让锁作用于当前的类对象。

某些情况下,整个方法体比较大,需要同步的代码只是一小部分,如果对整个方法体进行同步,会使代码性能变差,这时只需对一小部分代码进行同步即可。

 static int i = 0;   //共享资源
 @Override
 public void run() {
     //其他操作.......
     synchronized (this){   
    //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
         for (int j = 0; j < 10000; j++) {
             i++;
         }
     }
 }

5. Reentrantlock的实现原理?

Reentrant:可重入的

ReentrantLock特性概览 image.png

公平锁 在多个线程竞争获取锁时,公平锁倾向于将访问权授予等待时间最长的线程。 也就是说,公平锁相当于有一个线程等待队列,先进入队列的线程会先获得锁,按照 "FIFO(先进先出)" 的原则,对于每一个等待线程都是公平的。

非公平锁 非公平锁是抢占模式,线程不会关注队列中是否存在其他线程,也不会遵守先来后到的原则,直接尝试获取锁。

接下来进入正题,一起分析下 ReentrantLock 的底层是如何实现的。 ReentrantLock 实现的前提是 AbstractQueuedSynchronizer(抽象队列同步器),简称 AQS,是 java.util.concurrent 的核心,常用的线程并发类 CountDownLatch、CyclicBarrier、Semaphore、ReentrantLock 等都包括了一个继承自 AQS 抽象类的内部类。

同步标志位 state AQS 内部维护了一个同步标志位 state,用来实现同步加锁控制:

同步标志位 state 的初始值为 0,线程每加一次锁,state 就会加 1,也就是说,已经获得锁的线程再次加锁,state 值会再次加 1。可以看出,state 实际上表示的是已获得锁的线程进行加锁操作的次数。 。。。 后面内容非常多,不建议继续看了,等真正遇到再认真学吧。

6.Reentrantlock的使用方法?

// 4.synchronized可重入
for (int i = 0; i < 100; i++) {
	synchronized (this) {}
}

ReentrantLock 分为公平锁和非公平锁
//公平锁
ReentrantLock pairLock = new ReentrantLock(true);
//非公平锁
ReentrantLock pairLock1 = new ReentrantLock(false);
//如果构造函数不传递参数,则默认是非公平锁。
ReentrantLock pairLock2 = new ReentrantLock();


public void test () throw Exception {
    // 1.初始化选择公平锁、非公平锁
    ReentrantLock lock = new ReentrantLock(true);
    // 2.可用于代码块
    lock.lock();
    try {
        try {
            // 3.支持多种加锁方式,比较灵活; 具有可重入特性
            if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
        } finally {
            // 4.手动释放锁
            lock.unlock()
        }
    } finally {
        lock.unlock();
    }
}

7.什麼情况下使用 synchronized 和 ReentrantLock?

在非常复杂的同步应用中,请考虑使用ReentrantLock,特别是遇到下面2种需求的时候:

  1. 某个线程在等待一个锁的控制权这段时间需要中断;
  2. 需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程; 3.具有公平锁功能,每个到来的线程都将排队等候;

ReentrantLock的lock机制有2种,忽略中断锁和响应中断锁,这给我们带来了很大的灵活性。比如:如果A、B 2个线程去竞争锁,A线程得到了锁,B线程等待,但是A线程这个时候实在有太多事情要处理,就是一直不返回,B线程可能就会等不及了,想中断自己,不再等待这个锁了,转而处理其他事情。这个时候ReentrantLock就提供了2种机制:可中断/可不中断 第一,B线程中断自己(或者别的线程中断它),但是ReentrantLock不去响应,继续让B线程等待,你再怎么中断,我全当耳边风(synchronized原语就是如此); 第二,B线程中断自己(或者别的线程中断它),ReentrantLock处理了这个中断,并且不再等待这个锁的到来,完全放弃。

8. ReentrantReadWriteLock读锁共享

ReentrantReadWriteLock 之所以优秀,是因为读锁与写锁是分离的,当所有的线程都为读操作时,不会造成线程之间的互相阻塞,提升了效率。

public class DemoTest {
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();// 读写锁
    private int i;
    public String read() {
        try {
            lock.readLock().lock();// 占用读锁
            System.out.println(Thread.currentThread().getName()+"占用读锁");
            Thread.sleep(2000);
        } catch (InterruptedException e) {

        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放读锁,i->" + i);
            lock.readLock().unlock();// 释放读锁
        }
        return i + "";
    }
    public static void main(String[] args) {
        final DemoTest demo1 = new DemoTest();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                demo1.read();
            }
        };
        new Thread(runnable, "t1"). start();
        new Thread(runnable, "t2"). start();
        new Thread(runnable, "t3"). start();
    }
}