面试题——Java中的锁


谈谈你对线程安全的理解?

  • 谈到线程安全问题,就得先说一下什么是共享资源。所谓共享资源,就是说该资源被多个线程所持有或者说多个线程都可以去访问该资源。
  • 线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。

1、synchronized 关键字是怎么用的?

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

  • 主要有三种使用方法:
  1. 修饰实例方法: 给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

  2. 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。

  3. 修饰代码块指定加锁对象,对给定对象/类加锁。

    // 1.修饰实例方法
    synchronized void method1() {
    	//业务代码
    }
    //2.修饰静态方法
    synchronized static void method2() {
    	//业务代码
    }
    //3.修饰代码块
    synchronized(this) {
    	//业务代码
    }
    

1.1 构造方法可以使用 synchronized 关键字修饰么?

  • 不能。JAVA 语法规定构造方法不能被 synchronized 关键词修饰。
  • 前面说了,synchronized 关键字作用于方法上,是给当前对象实例/类加锁,而在构造方法上加 synchronized ,此时对象实例还没产生;另一方面,构造方法每次都是构造出新的对象,不存在多个线程同时读写同一对象中的属性的问题,所以不需要同步 。

1.2 使用 String 作为锁对象,会有什么问题?

  • 类似于 “String 对象创建了几个” 这样的问题,如果一个方法以参数中传来的 String 对象作为锁,那么就需要保证这个 String 对象在所有线程中的地址是一致的。

参考:https://blog.csdn.net/headingalong/article/details/86505420

1.3 synchronized 的底层原理有了解吗?

  • 每个 Java 对象都可以关联一个 Monitor 对象,也就是我们常说的
  • 使用 synchronized 关键字来同步代码块,在 JVM 层面使用了 monitorenter 和 monitorexit 指令来实现。
  • 在执行到 monitorenter 指令时,线程会尝试获取对象所对应的 Monitor 对象的所有权,只有获取到所有权的线程才能够执行同步代码块中的内容。而没有获取到 Monitor 所有权的线程会进入阻塞状态。

1.4 synchronized 怎么保证可重入性?可见性?抛异常怎么办?

可重入:

  • 可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁没有被任何线程占用。当一个钱程获取了该锁时,计数器的值会变成1 ,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
  • 但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1,当释放锁后计数器值-1 。当计数器值为0 时,锁里面的线程标示被重置为null , 这时候被阻塞的线程会被唤醒来竞争获取该锁。

但是线程标识、计数器在锁中如何存储的?等到看完 synchronized 的膨胀过程以及MarkWord再说。

抛异常:

  • synchronized 修饰代码块之后,在字节码指令层面,会有覆盖该代码块的异常表 Exception Table,当同步代码块内抛出异常,会执行相应的字节码指令,保证锁能够正常释放。

  • 还可以再追问一下:锁重入之后,内层抛出异常,计数器减一还是直接释放掉锁?

    • 内层抛出异常,能保证内层正常释放掉锁(即计数器减一)。如果该异常在外层捕获并处理,那么并不影响外层,也就是不会导致外层也释放锁。
    public class TestSynchronizedException {
        public static void main(String[] args) {
            Task task = new Task();
            // 两个线程同时执行一个任务
            Thread thread1 = new Thread(task);
            Thread thread2 = new Thread(task);
            thread1.start();
            thread2.start();
        }
    
    	static class Task implements Runnable{
            public synchronized void firstIn(){
                System.out.println(Thread.currentThread().getName() + "第一次进入");
                try {
                    secondIn(); // 捕获内层出现的异常
                } catch (Exception e) {
                    System.out.println(Thread.currentThread().getName() + e.getMessage());;
                }
    
    			// 等待 1s 之后继续执行外层的代码
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "继续执行");
        	}
    
            public synchronized void secondIn() throws Exception {
                System.out.println(Thread.currentThread().getName() + "第二次进入");
                throw new Exception("内层异常");
            }
    
            @Override
            public void run() {
                firstIn();
            }
    	}
    }
    
    • 执行结果:
    Thread-0第一次进入
    Thread-0第二次进入
    Thread-0内层异常
    Thread-0继续执行      //可以发现,内层出现异常,如果被捕获并处理,不会导致锁直接被释放
    Thread-1第一次进入
    Thread-1第二次进入
    Thread-1内层异常
    Thread-1继续执行
    

1.4 还使用过其他锁吗?(ReentrantLock)

  • 还使用过 ReentrantLock 可重入锁。
  • 相同点:和 synchronized 一样都是可重入锁 —— 即支持一个线程对资源的重复加锁。
  • 不同点:synchronized 是隐式地获取和释放锁的,而 ReentrantLock 需要显示地获取和释放锁,在锁获取和释放时有更多的可操作性,支持可中断地获取锁、超时获取锁、公平锁,并且可以创建多个条件变量 Condition,实现选择性通知。
  1. 可中断地获取锁:指的是处于阻塞状态等待锁的线程可以被打断等待
  2. **超时获取锁:**等待一段时间,另一线程仍然没有释放锁,那么不再等待 —— 本次获取锁失败
  3. **公平锁:**公平地锁获取,就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的,先对锁进行获取请求一定最先满足。

在这里插入图片描述

1.5 ReentrantLock 的实现原理了解吗?(公平锁、可重入、可中断是怎么实现的?)

首先讲AQS

  • 队列同步器 AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作。
  • AQS 的核心思想是,成功获取同步状态的线程会被设置为当前工作线程,而获取同步状态失败的线程会被加入到同步队列的尾部。

公平锁与非公平锁

  • 公平锁和非公平锁在获取同步状态失败之后,都会进入到 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断同步队列是否有线程,如果有则不去抢锁,被加入到同步队列尾部。

可重入

  • 如果线程获取同步状态时发现 state != 0,即锁已经被占有了,则会去判断当前线程是否是占有锁的线程,如果是,则仍可以获取到 state 并对 state 进行累加操作。

1.6 加锁会带来哪些性能问题?如何解决?

  • 带来的问题主要有:死锁、饥饿、线程切换带来的资源消耗等等。

JAVA并发之加锁导致的活跃性问题

  • 解决:减小资源的消耗方面——synchronized的优化;死锁的避免;饥饿可采用公平锁。

Java中锁的优化
锁的性能影响与优化

2、volatile 有什么作用?

volatile 的第一个语义:

  • Java 中,为了解决内存可见性问题,提供了一种形式的同步,也就是使用 volatile 关键字。
  • volatile 可以确保对一个变量的更新对其他线程马上可见。
  • 当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存
  • 当其他线程读取该共享变量时,会从主内存中重新获取最新值,而不是使用当前线程的工作内存中的值。
  • volatile 的内存语义和 synchronized 有相似之处 —— 当线程写入了 volatile 变量时就等价于线程退出 synchronized 同步块(把写入工作内存的变量同步到主内存),读取 volatile 变量时就相当于进入同步代码块(先清空本地内存的变量值,再从主内存获取最新值)。

volatile的第二个语义:

  • 禁止指令重排序优化。
  • 普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
  • 例如,在如上代码中,变量c 的值依赖 a 和b 的值,所以重排序后能够保证( 3 )的操作在( 2 ) ( 1 )之后, 但是( 1 ) ( 2 )谁先执行就不一定了,这在单线程下不会存在问题,因为并不影响最终结果。
    int a = 10; (1)
    int b = 20; (2)
    int c = a + b; (3)	
    
  • 再例如,常见的 DCL 单例模式(代码见文章末尾),如果不加 volatile 修饰,那么在多线程情况下,可能出现t1 线程先将引用地址赋值给了 instance 变量(1),之后才执行构造方程进行初始化(2),但是在 t1 线程执行到(1)时,t2 线程进来发现 instance 已经不为空了,直接返回了该实例,可是此时该实例还并没有初始化完毕。

2.1 原理是什么?

首先说一下 CPU 缓存的相关知识

  • CPU 读取数据的方式(顺序)为

    CPU <------>寄存器 <---->缓存<----->内存

  • 寄存器(register)是 CPU(中央处理器)的组成部分,是一种直接整合到 CPU 中的有限的高速访问速度的存储器。寄存器是一种容量有限的存储器,并且非常小。因此只把一些计算机的指令等一些计算机频繁用到的数据存储在其中,来提高计算机的运行速度。
  • 缓存 Cache :即高速缓冲存储器,是位于 CPU 与主内存间的一种容量较小但速度很高的存储器。CPU Cache 缓存的是内存数据,用于解决 CPU 处理速度和内存不匹配的问题。现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。(下图转载于 JavaGuide)

接着再说 Java 的内存模型(JMM)

  • 从抽象的角度看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储于主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存存储了以读/写共享变量的副本
  • 本地内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。(Java 内存模型的抽象示意图见下——《Java并发编程艺术》)
    在这里插入图片描述

    计算机硬件底层的内存结构过于复杂,JMM的意义在于避免程序员直接管理计算机底层内存,用一些关键字synchronized、volatile等可以方便的管理内存。

最后来看 volatile 的实现原理

  • 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。
  • 一个处理器的缓存回写到内存会导致其他处理器的该缓存行无效,当处理器对这个数据进行操作的时候,会重新从系统内存中把数据读到处理器缓存中

或者说:更底层使用 lock 指令,在对 volatile 变量的读写时加入内存屏障

  • 对 volatile 变量的写指令会加入写屏障
  • 对 volatile 变量的读指令会加入读屏障

实现下面的作用:

  • 可见性
    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  • 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

2.2 和 synchronized 有什么区别?

  • volatile 关键字是线程同步的轻量级实现,不会引起线程上下文的切换和调度。
  • 但是 volatile 关键字只能用于变量。
  • volatile关键字主要用于解决变量在多个线程之间的可见性不能保证数据的原子性
  • synchronized 关键字两者都能保证

3. 写一个单例模式

public class Singleton{
	private static volatile Singleton instance;
	// 构造方法私有化
	private Singleton(){}
	
	public static Singleton getInstance(){
		if(instance == null){
			synchronized(Singleton.class){
				if(instance == null){
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值