文章目录

https://www.nowcoder.com/discuss/697691

题目:Java锁有了解吗?Synchronized和ReentrantLock区别?说说如何ReentrantLock如何实现超时锁的等待?

1、什么是线程安全和线程不安全?

当多个线程并发访问某个Java对象时,无论系统如何调度这些线程,也无论这些线程将如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。

如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。

2、为什么i 运算不是线程安全的?

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

public class ThreadDemo { // 成员变量,线程共享资源 private static int i=0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int j = 0; j < 5000; j ) { i ; } }); Thread t2 = new Thread(() -> { for (int j = 0; j < 5000; j ) { i--; } }); t1.start(); t2.start(); // 主线程等待t1线程和t2线程执行结束再继续执行 t1.join(); t2.join(); System.out.println(i); } }

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。

例如对于 i 而言,实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值 iconst_1 // 准备常量1 iadd // 自增 putstatic i // 将修改后的值存入静态变量i

Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(1)

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(2)

但多线程下这 8 行代码可能交错运行,比如出现负数的情况:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(3)

因此,一个自增运算符是一个复合操作,至少包括三个JVM指令:“内存取值”“寄存器增加1”和“存值到内存”。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。“内存取值”“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性了。比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。

3、什么情况下会出现线程安全问题?

在多个线程操作相同资源(如变量、数组或者对象)时就可能出现线程安全问题。一般来说,只在多个线程对这个资源进行写操作的时候才会出现问题,如果是简单的读操作,不改变资源的话,显然是不会出现问题的。

一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源,多个线程读共享资源其实也没有问题,而在多个线程对共享资源读写操作时发生指令交错,就会出现问题 ;

4、什么是临界区资源、临界区代码、竞态条件?

临界区资源表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。在并发情况下,临界区资源是受保护的对象。

临界区代码段是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后执行临界区代码段,执行完成之后释放资源。临界区代码段的进入和退出如图所示:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(4)

竞态条件可能是由于在访问临界区代码段时没有互斥地访问而导致的特殊情况。如果多个线程在临界区代码段的并发执行结果可能因为代码的执行顺序不同而不同,我们就说这时在临界区出现了竞态条件问题。

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区代码块;

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件;

public class SafeDemo { // 临界区资源 private static int i = 0; // 临界区代码段 public void selfIncrement(){ for(int j=0;j<5000;j ){ i ; } } // 临界区代码段 public void selfDecrement(){ for(int j=0;j<5000;j ){ i--; } } // 这个不是临界区代码,因为虽然使用了共享资源,但是这个方法并没有被多个线程同时访问 public int getI(){ return i; } } public class ThreadDemo { public static void main(String[] args) throws InterruptedException { SafeDemo safeDemo = new SafeDemo(); Thread t1 = new Thread(()->{ safeDemo.selfIncrement(); }); Thread t2 = new Thread(()->{ safeDemo.selfDecrement(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(safeDemo.getI()); } }

当多个线程访问临界区的selfIncrement()方法时,就会出现竞态条件的问题。更标准地说,当两个或多个线程竞争同一个资源时,对资源的访问顺序就变得非常关键。为了避免竞态条件的问题,我们必须保证临界区代码段操作具备排他性。这就意味着当一个线程进入临界区代码段执行时,其他线程不能进入临界区代码段执行。

在Java中,可以使用synchronized关键字,使用Lock显式锁实例,或者使用原子变量对临界区代码段进行排他性保护。

5、Synchronized 对象锁?

synchronized 即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

Java中的每一个对象都可以作为锁:

1)对于同步方法,锁是当前实例对象。

2)对于静态同步方法,锁是当前对象的Class对象。

3)对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

6、Synchronized 同步方法?

public class SafeDemo { // 临界区资源 private static int i = 0; // 临界区代码使用synchronized关键字进行保护 public synchronized void selfIncrement(){ for(int j=0;j<5000;j ){ i ; } } public int getI(){ return i; } }

public class ThreadDemo { public static void main(String[] args) throws InterruptedException { SafeDemo safeDemo = new SafeDemo(); // 线程1和线程2同时执行临界区代码段 Thread t1 = new Thread(()->{ safeDemo.selfIncrement(); }); Thread t2 = new Thread(()->{ safeDemo.selfIncrement(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(safeDemo.getI()); // 9906 } }

在方法声明中设置synchronized同步关键字,保证其方法的代码执行流程是排他性的。任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队。

7、Synchronized 同步方法的锁对象?

1、定义线程的执行逻辑

public class ThreadTask { // 临界区代码使用synchronized关键字进行保护 public synchronized void test() { try { System.out.println(Thread.currentThread().getName() " begin"); Thread.sleep(1000); System.out.println(Thread.currentThread().getName() " end"); } catch (InterruptedException e) { e.printStackTrace(); } } }

2、分别创建两个线程,在两个线程的执行体中执行线程逻辑:

public class ThreadA extends Thread { ThreadTask threadTask ; public ThreadA(ThreadTask threadTask){ super(); this.threadTask = threadTask; } @Override public void run() { threadTask.test(); } }

public class ThreadB extends Thread { ThreadTask threadTask ; public ThreadB(ThreadTask threadTask){ super(); this.threadTask = threadTask; } @Override public void run() { threadTask.test(); } }

3、创建一个锁对象,传给两个线程:

public class Main { public static void main(String[] args) throws InterruptedException { ThreadTask threadTask = new ThreadTask(); ThreadA t1 = new ThreadA(threadTask); ThreadB t2 = new ThreadB(threadTask); t1.start(); t2.start(); } }

这里两个线程的锁对象都是threadTask,所以同一时间只有一个线程能拿到这个锁对象,执行同步代码块。

总结:synchronized方法的同步锁实质上使用了this对象锁

(1) A线程先持有object对象的锁,B线程如果在这时调用object对象的synchronized同步方法,则需等待,也就是同步;

(2) 在方法声明处添加synchronized并不是锁方法,而是锁当前类的对象,在Java中只有将对象作为锁,并没有锁方法这种说法;

(3) 在Java语言中,锁就是对象,对象可以映射成锁,哪个线程拿到这把锁,哪个线程就可以执行这个对象中的synchronized同步方法;

(4) 如果在 X 对象中访问了synchronized关键字声明非静态方法,则 X 对象就被当成锁;

8、Synchronized 静态同步方法的锁对象?

Java有两种对象:Object实例对象和Class对象。每个类运行时的类型信息用Class对象表示,它包含与类名称、继承关系、字段、方法有关的信息。

public class StaticSafe { // 临界资源 private static int count = 0; // 使用synchronized关键字修饰static方法 public static synchronized void test(){ count ; } }

静态方法属于Class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用的。所以,修饰static方法的synchronized关键字就没有办法获得Object实例的this对象的监视锁。

实际上,使用synchronized关键字修饰static方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。

为了以示区分,这里将Object对象的监视锁叫作对象锁,将Class对象的监视锁叫作类锁。当synchronized关键字修饰static方法时,同步锁为类锁;当synchronized关键字修饰普通的成员方法时,同步锁为对象锁。由于类的对象实例可以有很多,但是每个类只有一个Class实例,因此使用类锁作为synchronized的同步锁时会造成同一个JVM内的所有线程只能互斥地进入临界区段。

所以,使用synchronized关键字修饰static方法是非常粗粒度的同步机制。

9、如果同步方法/块 内的线程抛出异常,则对象锁自动释放?

public class SafeDemo { public synchronized void selfIncrement(){ if(Thread.currentThread().getName().equals("t1")){ System.out.println("t1 线程正在运行"); int a=1; // 死循环,只要t1线程没有执行完这个方法,就不会释放锁 while (a==1){ } }else{ System.out.println("t2 线程正在运行"); } } }

public class ThreadDemo { public static void main(String[] args) throws InterruptedException { SafeDemo safeDemo = new SafeDemo(); Thread t1 = new Thread(()->{ safeDemo.selfIncrement(); },"t1"); Thread t2 = new Thread(()->{ safeDemo.selfIncrement(); },"t2"); t1.start(); t2.start(); t1.join(); t2.join(); } }

执行结果:t2线程得不到执行

t1 线程正在运行

此时,如果我们在同步方法中制造一个异常:

public class SafeDemo { public synchronized void selfIncrement(){ if(Thread.currentThread().getName().equals("t1")){ System.out.println("t1 线程正在运行"); int a=1; while (a==1){ Integer.parseInt("a"); } }else{ System.out.println("t2 线程正在运行"); } } }

执行结果:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(5)

线程t1出现异常并释放锁,线程t2进入方法正常输出,说明出现异常时,锁被自动释放了。

10、 Synchronized 关键字为什么不必担心锁的释放问题?

通过synchronized关键字所抢占的同步锁什么时候释放呢?一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。

11、Synchronized同步方法和同步块,哪种更好?

同步块更好,这意味着同步块之外的代码是异步执行的,这比同步整个方法更提升代码的效率。请知道一条原则:同步的范围越小越好。

对于小的临界区,我们直接在方法声明中设置synchronized同步关键字,可以避免竞态条件的问题。但是对于较大的临界区代码段,为了执行效率,最好将同步方法分为小的临界区代码段。

public class TwoPlus { private int num1 = 0; private int num2 = 0; public synchronized void plus(int val1,int val2){ this.num1 = num1 val1; this.num2 = num2 val2; } }

临界区代码段包含对两个临界区资源的操作,这两个临界区资源分别为sum1和sum2。使用synchronized对plus(int val1,int val2)进行同步保护之后,进入临界区代码段的线程拥有sum1和sum2的操作权,并且是全部占用。一旦线程进入,当线程在操作sum1而没有操作sum2时,也将sum2的操作权白白占用,其他的线程由于没有进入临界区,只能看着sum2被闲置而不能去执行操作。

所以,将synchronized加在方法上,如果其保护的临界区代码段包含的临界区资源多于一个,就会造成临界区资源的闲置等待,进而会影响临界区代码段的吞吐量。为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。

public class TwoPlus { private int num1 = 0; private int num2 = 0; // 两把不同的锁对象 private Object object1 = new Object(); private Object object2 = new Object(); public void plus(int val1,int val2){ synchronized (object1){ this.num1 = num1 val1; } synchronized (object2){ this.num2 = num2 val2; } } }

改造之后,对两个独立的临界区资源sum1和sum2的加法操作可以并发执行了,在某一个时刻,不同的线程可以对sum1和sum2同时进行加法操作,提升了plus()方法的吞吐量。

12、Synchronized 方法和 Synchronized 同步块有什么区别呢?

总体来说,synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多个线程并发访问的。在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。所以synchronized代码块比synchronized方法更加细粒度地控制了多个线程的同步访问。

synchronized方法的同步锁实质上使用了this对象锁,这样就免去了手工设置同步锁的工作。而使用synchronized代码块需要手工设置同步锁。

13、如何理解 Synchronized 同步代码块?

public class RoomDemo { private static int count = 0; // 创建锁对象,同步代码块需要手动设置对象锁 private static Object object = new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for(int i=0;i<5000;i ){ // 使用object对象锁住临界区资源 synchronized (object){ count ; } } },"t1"); Thread t2 = new Thread(()->{ // 使用object对象锁住临界区资源 for(int i=0;i<5000;i ){ synchronized (object){ count--; } } },"t2"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count);// 0 } }

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(6)

你可以做这样的类比: synchronized(对象) 中的对象,可以想象为一个房间,线程 t1,t2 想象成两个人

(1) 当线程 t1 执行到 synchronized(object) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行 count 代码 ;

(2) 这时候如果 t2 也运行到了 synchronized(object) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了 ;

(3) 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外 (不要错误理解为锁住了对象就能一直执行下去哦) , 这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才 能开门进入

(4) 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程并把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码;

14、Java对象的结构?

Java对象结构包括三部分:对象头、对象体和填充字节,如图所示:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(7)

15、不同锁状态下的32位Mark Word结构?

Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁。其实在JDK 1.6之前,Java内置锁还是一个重量级锁,是一个效率比较低下的锁,在JDK 1.6之后,JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁和轻量级锁,从此以后Java内置锁的状态就有了4种,并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级。

1)不同锁状态下的Mark Word字段结构:

Mark Word字段的结构与Java内置锁的状态强相关。为了让Mark Word字段存储更多的信息,JVM将Mark Word最低两个位设置为Java内置锁状态位,不同锁状态下的32位Mark Word结构如表所示:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(8)

16、不同锁状态下的64位Mark Word结构?

64位的Mark Word与32位的Mark Word结构相似,结构如表所示:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(9)

(1) lock:锁状态标记位,占两个二进制位,由于希望用尽可能少的二进制位表示尽可能多的信息,因此设置了lock标记。该标记的值不同,整个Mark Word表示的含义就不同。

(2) biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock两个标记位组合在一起共同表示Object实例处于什么样的锁状态。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(10)

(3) ptr_to_lock_record:占62位,在轻量级锁的状态下指向栈帧中锁记录的指针。

(4) ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下指向对象监视器的指针。

17、无锁、偏向锁、轻量级锁和重量级锁

在JDK 1.6版本之前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间频繁切换,所以代价高、效率低。JDK 1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁的实现。所以,在JDK 1.6版本中内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能再降级成偏向锁。这种能升级却不能降级的策略,其目的是提高获得锁和释放锁的效率。

(1) 无锁状态 :

Java对象刚创建时还没有任何线程来竞争,说明该对象处于无锁状态(无线程竞争它),这时偏向锁标识位是0,锁状态是01;

(2) 偏向锁状态:

偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。

偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,内置锁会将该线程当作自己的熟人,这时偏向锁标识位是1,锁状态是01;

(3) 轻量级锁状态:

当有两个线程开始竞争这个锁对象时,情况就发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。这时偏向锁标识位是0,锁状态是00;

当锁处于偏向锁,又被另一个线程企图抢占时,偏向锁就会升级为轻量级锁。企图抢占的线程会通过自旋的形式尝试获取锁,不会阻塞抢锁线程,以便提高性能。

自旋原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要进行内核态和用户态之间的切换来进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程和内核切换的消耗。

但是,线程自旋是需要消耗CPU的,如果一直获取不到锁,那么线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JVM对于自旋周期的选择,JDK 1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少。

如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。

(4) 重量级锁状态:

重量级锁会让其他申请的线程之间进入阻塞,性能降低。重量级锁也叫同步锁,这个锁对象MarkWord再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。这时偏向锁标识位是0,锁状态是10;

18、Synchronized 偏向锁的核心原理?

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。 Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

public class Main { static final Object obj = new Object(); public static void main(String[] args) { Thread thread = new Thread(()->{ m1(); }); thread.start(); } public static void m1() { synchronized( obj ) { // 同步块 A m2(); } } public static void m2() { synchronized( obj ) { // 同步块 B m3(); } } public static void m3() { synchronized( obj ) { //偏向状态 // 同步块 C } } }

偏向锁的核心原理是:如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为01,偏向标志位(biased_lock)被改为1,然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完成)。以后该线程获取锁时判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。

偏向锁的主要作用是消除无竞争情况下的同步原语,进一步提升程序性能,所以,在没有锁竞争的场合,偏向锁有很好的优化效果。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。

假如在大部分情况下同步块是没有竞争的,那么可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己,如果是,那么该线程将不用再次获得锁,直接就可以进入同步块;如果未指向当前线程,当前线程就会采用CAS操作将Mark Word中的线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(11)

偏向锁的加锁过程为:新线程只需要判断内置锁对象的Mark Word中的线程ID是不是自己的ID,如果是就直接使用这个锁,而不使用CAS交换;如果不是,比如在第一次获得此锁时内置锁的线程ID为空,就使用CAS交换,新线程将自己的线程ID交换到内置锁的Mark Word中,如果交换成功,就加锁成功。

每执行一轮抢占,JVM内部都会比较内置锁的偏向线程ID与当前线程ID,如果匹配,就表明当前线程已经获得了偏向锁,当前线程可以快速进入临界区。所以,偏向锁的效率是非常高的。总之,偏向锁是针对一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。

偏向锁的缺点:如果锁对象时常被多个线程竞争,偏向锁就是多余的,并且其撤销的过程会带来一些性能开销。

19、Synchronized 偏向锁的撤销和膨胀?

1、偏向锁的撤销:

假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁。

偏向锁撤销的开销花费还是挺大的,其大概过程如下:

(1) 在一个安全点停止拥有锁的线程。

(2) 遍历线程的栈帧,检查是否存在锁记录。如果存在锁记录,就需要清空锁记录,使其变成无锁状态,并修复锁记录指向的Mark Word,清除其线程ID。

(3) 将当前锁升级成轻量级锁。

(4) 唤醒当前线程。

所以,如果某些临界区存在两个及两个以上的线程竞争,那么偏向锁反而会降低性能。在这种情况下,可以在启动JVM时就把偏向锁的默认功能关闭。

2、偏向锁的膨胀:

如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。

如果JVM检查到原来的线程依然存活,就进一步检查占有线程的调用堆栈是否通过锁记录持有偏向锁。如果存在锁记录,就表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。

3、偏向锁的好处:

经验表明,其实大部分情况下进入一个同步代码块的线程都是同一个线程。这也是JDK会引入偏向锁的原因。所以,总体来说,使用偏向锁带来的好处还是大于偏向锁撤销和膨胀所带来的代价。

20、Synchronized 轻量级锁的核心原理?

引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以 使用轻量级锁来优化。

轻量锁存在的目的是尽可能不动用操作系统层面的互斥锁,因为其性能比较差。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁地阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了轻量级锁。

轻量级锁的执行过程:

(1) 在抢锁线程进入临界区之前,如果内置锁没有被锁定,JVM首先将在抢锁线程的栈帧中建立一个锁记录(Lock Record),每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的mark word

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(12)

(2) 抢锁线程将使用CAS自旋操作,尝试将内置锁对象头的mark word的ptr_to_lock_record(锁记录指针)更新为抢锁线程栈帧中锁记录的地址,如果这个更新执行成功了,这个线程就拥有了这个对象锁。然后jvm将mark word中的lock标记位改为00,即表示该对象处于轻量级锁状态。

抢锁成功之后,jvm会将mark word中原来的锁对象信息(如哈希码等)保存在抢锁线程锁记录的Displaced Mark Word字段中,再将抢锁线程中锁记录的owner指针指向锁对象。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(13)

(3) 如果 cas 失败,有两种情况:

● 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程 ;

● 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数;

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(14)

(4) 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(15)

(5) 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 mark word的值恢复给对象头

成功,则解锁成功

失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

21、Synchronized 轻量级锁的分类:普通自旋锁和自适应自旋锁。

1)普通自旋锁

所谓普通自旋锁,就是指当有线程来竞争锁时,抢锁线程会在原地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这个抢锁线程才可以获得锁。

说明:

锁在原地循环等待的时候是会消耗CPU的,就相当于在执行一个什么也不干的空循环。所以轻量级锁适用于临界区代码耗时很短的场景,这样线程在原地等待很短的时间就能够获得锁了。默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin选项来进行更改。

2)自适应自旋锁

所谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。自适应自旋锁的大概原理是:

如果抢锁线程在同一个锁对象上之前成功获得过锁,jvm就会认为这次自旋很有可能再次成功,因此允许自旋等待持续相对更长的时间。

如果对于某个锁,抢锁线程很少成功获得过,那么jvm将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。

自适应自旋解决的是“锁竞争时间不确定”的问题。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定。总的思想是:根据上一次自旋的时间与结果调整下一次自旋的时间。

JDK 1.6的轻量级锁使用的是普通自旋锁,且需要使用-XX: UseSpinning选项手工开启。

JDK 1.7后,轻量级锁使用自适应自旋锁,JVM启动时自动开启,且自旋时间由JVM自动控制。

轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待。

22、Synchronized 轻量级锁的膨胀

轻量级锁的问题在哪里呢?

虽然大部分临界区代码的执行时间都是很短的,但是也会存在执行得很慢的临界区代码。临界区代码执行耗时较长,在其执行期间,其他线程都在原地自旋等待,会空消耗CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋),这会带来很大的性能损耗。

轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁的概率,并不是要替代操作系统互斥锁。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁。

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

(1) 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(16)

这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,即为锁对象申请 Monitor 锁,让锁对象指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(17)

当 Thread-0 退出同步块解锁时,使用 cas 将mark word的值恢复给对象头,失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。

23、Monitor监视器原理

在JVM中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。

JVM中每个对象都会有一个监视器Monitor,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。

每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C 实现)

//部分属性 ObjectMonitor() { _count = 0; //锁计数器 _owner = NULL; _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet _EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表 }

本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:

1)同步:监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。

2)协作:监视器提供 Signal 机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal 去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(18)

每个java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的mark word中就被设置指向 Monitor 对象的指针。

(1) 如果使用 synchronized 给obj对象上锁,obj对象的markword就会指向一个monitor锁对象;

(2) 刚开始 Monitor 中 Owner 为 null ;

(3) 当Thread-2线程持有monitor对象后,就会把monitor中的owner变量设置为当前线程Thread-2;

(4) 当Thread-3线程想要执行临界区的代码时,要判断monitor对象的属性Owner是否为null,如果为null,Thread-3线程就持有了对象锁,如果不为null,Thread-3线程就会放入monitor的EntryList阻塞队列中,处于阻塞状态Blocked。

(5) 在 Thread-2 上锁的过程中,如果Thread-4,Thread-5 也来执行 synchronized(obj),也会进入EntryList BLOCKED ;

(6) Thread-2 执行完同步代码块的内容,就会释放锁,将owner变量置为null,并唤醒EntryList 中阻塞的线程来竞争锁,竞争时是非公平的 ;

(7) 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析。

24、Snychronized同步代码块原理

public class TestSynchronized { static final Object obj = new Object(); static int i=0; public static void main(String[] args) { synchronized (obj){ i ; } } } public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: getstatic #2 // 获取obj对象 3: dup 4: astore_1 5: monitorenter //将obj对象markword中指向重量级锁的指针指向monitor对象 6: getstatic #3 9: iconst_1 10: iadd 11: putstatic #3 14: aload_1 15: monitorexit //同步代码块正常执行时,将obj对象的markword重置,唤醒EntryList 16: goto 24 19: astore_2 20: aload_1 21: monitorexit //同步代码块出现异常时,将obj对象的markword重置,唤醒EntryList 22: aload_2 23: athrow 24: return Exception table: from to target type 6 16 19 any //监测6-16行jvm指令,如果出现异常就会到第19行 19 22 19 any

monitorenter主要是获取监视器锁,monitorexit主要是释放监视器锁。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

monitorenter 指令:

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

(1) 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者

(2) 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

(3) 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(19)

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(20)

monitorexit指令:

(1) 执行monitorexit的线程必须是持有obj锁对象的线程

(2) 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程释放monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权

monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁。monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。所以 synchroznied 同步锁出现异常会释放锁 ,不用考虑释放锁的问题。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(21)

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(22)

25、Synchronized同步方法原理

public class TestSynchronized { static int i=0; public synchronized void add(){ i ; } } 对应的字节码指令: public synchronized void add(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field i:I 3: iconst_1 4: iadd 5: putstatic #2 // Field i:I 8: return

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

26、Synchronized重量级锁的开销?

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。

由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因。

27、Synchronized锁升级的过程?

synchronized的执行过程,大致如下:

(1) 线程抢锁时,JVM首先检测内置锁对象Mark Word中的biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。

(2) 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中的线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。

(3) 如果Mark Word中的线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中的线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。

(4) 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。

(5) JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。

(6) 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁就膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。

总体来说,偏向锁是在没有发生锁争用的情况下使用的;一旦有了第二个线程争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(23)

28、什么是原子性 ?

原子性、可见性、有序性是并发编程所面临的三大问题。

所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。

例如对于 i 而言,实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值(内存取值) iconst_1 // 准备常量1 iadd // 自增 (寄存器增加1) putstatic i // 将修改后的值存入静态变量i(存值到内存)

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(24)

但多线程下这 8 个指令可能交错运行,导致出现原子性问题,比如结果为负数的情况:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(25)

一个自增运算符是一个复合操作,“内存取值”“寄存器增加1”和“存值到内存”这三个JVM指令本身是不可再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性了。因为这4个操作之间是可以发生线程切换的,或者说是可以被其他线程中断的。所以,i 操作不是原子操作,在并行场景会发生原子性问题。

29、什么是可见性?

一个线程对共享变量的修改,另一个线程能够立刻可见,我们称该共享变量具备内存可见性。

谈到内存可见性,要先引出Java内存模型的概念。JMM规定,将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作内存(私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。

如果两个线程同时操作一个共享变量,就可能发生可见性问题:

(1) 主存中有变量sum,初始值sum=0;

(2) 线程A计划将sum加1,先将sum=0复制到自己的私有内存中,然后更新sum的值,线程A操作完成之后其私有内存中sum=1,然而线程A将更新后的sum值回刷到主存的时间是不固定的;

(3) 在线程A没有回刷sum到主存前,刚好线程B同样从主存中读取sum,此时值为0,和线程A进行同样的操作,最后期盼的sum=2目标没有达成,最终sum=1;

线程A和线程B并发操作sum发生内存可见性问题:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(26)

要想解决多线程的内存可见性问题,所有线程都必须将共享变量刷新到主存,一种简单的方案是:使用Java提供的关键字volatile修饰共享变量。

为什么Java局部变量、方法参数不存在内存可见性问题?

在Java中,所有的局部变量、方法定义参数都不会在线程之间共享,所以也就不会有内存可见性问题。所有的Object实例、Class实例和数组元素都存储在JVM堆内存中,堆内存在线程之间共享,所以存在可见性问题。

30、什么是有序性?

所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。

@Slf4j public class Test3 { private static volatile int x=0,y=0; private static int a=0,b=0; public static void main(String[] args) throws InterruptedException { for(int i=0;;i ){ a=0; b=0; x=0; y=0; Thread t1 = new Thread(() -> { a = 1; x = b; }); Thread t2 = new Thread(() -> { b = 1; y = a; }); t1.start(); t2.start(); t1.join(); t2.join(); // 假如t1线程先执行,t2线程后执行,则结果为a=1,x=0,b=1,y=1 (0,1) // 假如t2线程先执行,t1线程后执行,则结果为b=1,y=0,a=1,x=1 (1,0) // 假如t1线程和t2线程的指令是同时或交替执行的,则结果为a=1,b=1,x=1,y=1 (1,1) // 但是不可能出现(0,0) if(x==0 && y==0){ log.debug("x:{}, y:{}",x,y); } } } }

由于并发执行的无序性,赋值之后x、y的值可能为(1,0)、(0,1)或(1,1)。为什么呢?因为线程t1可能在线程t2开始之前就执行完了,也可能线程t2在线程t1开始之前就执行完了,甚至有可能二者的指令是同时或交替执行的。

然而,执行以上代码时,出乎意料的事情发生了:这段代码的执行结果也可能是(0,0),部分结果如下:

19:37:32.113 [main] DEBUG com.example.test.Test3 - x:0, y:0 19:37:33.041 [main] DEBUG com.example.test.Test3 - x:0, y:0 19:37:34.501 [main] DEBUG com.example.test.Test3 - x:0, y:0 19:37:41.825 [main] DEBUG com.example.test.Test3 - x:0, y:0

于以上程序来说,(0,0)结果是错误的,意味着已经发生了并发的有序性问题。为什么会出现(0,0)结果呢?可能在程序的执行过程中发生了指令重排序。对于线程t1来说,可能a=1和x=b这两个语句的赋值操作顺序被颠倒了,对于线程t2来说,可能b=1和y=a这两个语句的赋值操作顺序被颠倒了,从而出现了(x,y)值为(0,0)的错误结果。

31、什么是指令重排序?

一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。

重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,因此会导致工作线程似乎表现出了随机行为。指令重排序不会影响单个线程的执行,但是会影响多个线程并发执行的正确性。

事实上,输出了乱序的结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出。但是,指令重排序也是导致乱序的原因之一。

总之,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有得到保证,就有可能会导致程序运行不正确。

32、什么是Java内存模型?

java内存模型定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。实际上,JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。

1)java内存模型定义的两个概念:

java内存模型规定所有的变量都存储在主存中,每个Java线程都有自己的工作内存。

主存:所有线程都共享的内存 (方法区和堆),主要存储的是java实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法中的局部变量,当然也包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多条线程对同一个变量进行访问可能会发现线程安全问题。

工作内存:每个线程独享的内存(虚拟机栈、本地方法栈、程序计数器),主要存储当前方法的所有本地变量信息(工作内存中存储着主存中的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,即使两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

2)java内存模型的规定如下:

(1) 所有变量存储在主存中。

(2) 每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。

(3) 不同线程之间无法直接访问彼此工作内存中的变量,要想访问只能通过主存来传递。

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(27)

jmm将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存中,线程对变量的读写操作是自己的工作内存中的变量副本。因此,jmm模型也需要解决代码重排序和缓存可见性问题。jmm提供了一套自己的方案去禁用缓存以及禁止重排序来解决这些可见性和有序性问题。

jmm提供的方案包括volatile、synchronized、final等。jmm定义了一些内存操作的抽象指令集,然后将这些抽象指令包含到java的volatile、synchronized等关键字的语义中,并要求jvm在实现这些关键字时必须具备其包含的jmm抽象指令的能力。

33、JMM的8个操作?抽象指令集

java内存模型规定所有的变量都存储在主存中,每个线程都有自己的工作内存,工作内存保存了线程使用到的变量的拷贝副本,线程对变量的所有操作(读取、赋值等)必须在该线程的工作内存中进行。

jmm定义了一套自己的主存与工作内存之间的交互协议,即一个变量如何从主存拷贝到工作内存,又如何从工作内存写入主存,该协议包含8种操作,并且要求jvm具体实现必须保证其中每一种操作都是原子的、不可再分的。JMM主存与工作内存之间的交互协议的8种操作如下:

1)read (读取):将主存中一个变量的值传递到工作内存中,以便后续的load操作;

2)load (载入):将read从主存中读取的变量值载入到工作内存的变量副本中;

3)use (使用):将工作内存中的一个变量的值传递给执行引擎(使用变量值);

4)assign (赋值):执行引擎通过assign操作给工作内存中的变量赋值;

5)store (存储):将工作内存中的一个变量的值传递到主存中,以便后续的write操作;

6)write (写入):把store操作从工作内存中得到的变量值放入主存的变量;

7)lock (锁定):把一变量标识为某个线程独占状态;

8)unlock (解锁):把一个处于锁定状态的变量释放出来;

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(28)

如果要把一个变量从主存复制到工作内存,就要按顺序执行read和load操作;如果要把变量从工作内存同步回主存,就要按顺序执行store和write操作。

34、JMM如何解决有序性问题?

jmm提供了自己的内存屏障指令,要求jvm编译器实现这些指令,禁止特定类型的编译器和CPU重排序。jmm内存屏障主要包括读屏障和写屏障:

(1) 读屏障:在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主存加载数据。

(2) 写屏障:在写指令之后插入写屏障,能让写入缓存的最新数据写回主存。

35、Lock显示锁接口的常用API

与Java内置锁不同,JUC显式锁是一种非常灵活的、使用纯Java语言实现的锁,可以进行无条件的、可轮询的、定时的、可中断的锁获取和释放操作。由于JUC锁加锁和解锁的方法都是通过Java API显式进行的,因此也叫显式锁。

Lock接口位于java.util.concurrent.locks包中,是JUC显式锁的一个抽象,Lock接口的主要抽象方法:

java的lock和unlock方法使用(Java锁有了解吗Synchronized和ReentrantLock区别)(29)

1)void lock():抢锁。如果成功则向下运行,如果失败则阻塞抢锁线程。

2)void lockInterruptibly() throws InterruptedException:可中断抢锁,当前线程在抢锁的过程中可以响应中断信号。

3)boolean tryLock():尝试抢锁,线程为非阻塞模式,如果抢锁成功返回true,如果抢锁失败返回false。

4)boolean tryLock(long time,TimeUtil util) throws InterruptedException :限时抢锁,到达超时时间返回false,并且此限时抢锁方法也可以响应中断信号。

5)void unlock():释放锁。

6)Condition newCondition() 获取与显式锁绑定的Condition对象,用于”等待-通知“方式的线程间通信。

36、Lock显示锁和Synchronized内置锁的区别?

从Lock提供的接口方法可以看出,显式锁至少比Java内置锁多了以下优势:

1)可中断获取锁: 使用synchronized关键字获取锁的时候,如果线程没有获取到被阻塞,阻塞期间该线程是不响应中断信号(interrupt)的;而调用Lock.lockInterruptibly()方法获取锁时,如果线程被中断,线程将抛出中断异常。

2)可非阻塞获取锁:使用synchronized关键字获取锁时,如果没有成功获取,线程只有被阻塞;而调用Lock.tryLock()方法获取锁时,如果没有获取成功,线程也不会被阻塞,而是直接返回false。

3)可限时抢锁: 调用Lock.tryLock(long time,TimeUnit unit)方法,显式锁可以设置限定抢占锁的超时时间。而在使用synchronized关键字获取锁时,如果不能抢到锁,线程只能无限制阻塞。

37、Loc显式锁 - 如何调用lock()方法阻塞式抢锁?

通常情况下,大家会调用lock()方法进行阻塞式的锁抢占,其模板代码如下:

// 创建锁对象,SomeLock是Lock接口的实现类

Lock lock = new SomeLock();

// 抢占锁

lock.lock();

try {

// 抢锁成功,执行临界区代码

}finally {

// 释放锁

lock.unlock();

}

以上抢锁模板代码有以下几个需要注意的要点:

(1)释放锁操作lock.unlock()必须在try-catch结构的finally块中执行,否则,如果临界区代码抛出异常,锁就有可能永远得不到释放。

(2)抢占锁操作lock.lock()必须在try语句块之外,而不是放在try语句块之内。为什么呢?原因之一是lock()方法没有申明抛出异常,所以可以不包含到try块中;原因之二是lock()方法并不一定能够抢占锁成功,如果没有抢占成功,当然也就不需要释放锁,而且在没有占有锁的情况下去释放锁,可能会导致运行时异常。

(3)在抢占锁操作lock.lock()和try语句之间不要插入任何代码,避免抛出异常而导致释放锁操作lock.unlock()执行不到,导致锁无法被释放。

38、Loc显式锁 - 如何调用tryLock()方法非阻塞抢锁?

lock()是阻塞式抢占,在没有抢到锁的情况下,当前线程会阻塞。如果不希望线程阻塞,可以调用tryLock()方法抢占锁。tryLock()是非阻塞抢占,在没有抢到锁的情况下,当前线程会立即返回,不会被阻塞。

// 创建锁对象,SomeLock是Lock接口的实现类 Lock lock = new SomeLock(); // 尝试抢占锁 if(lock.tryLock()){ try { // 抢锁成功,执行临界区代码 }finally { // 释放锁 lock.unlock(); } }else{ //抢锁失败,执行后续操作 }

调用tryLock()方法时,线程拿不到锁就立即返回,这种处理方式在实际开发中使用不多,但是其重载版本tryLock(long time,TimeUnit unit)方法在限时阻塞抢锁的场景中非常有用。

39、Loc显式锁 - 如何调用tryLock(long time,TimeUnit unit)方法非阻塞抢锁?

tryLock(long time,TimeUnit unit)方法用于限时抢锁,该方法在抢锁时会进行一段时间的阻塞等待,其中的time参数代表最大的阻塞时长,unit参数为时长的单位(如秒)。

调用tryLock(long time,TimeUnit unit)方法限时抢锁,其大致的代码模板如下:

// 创建锁对象,SomeLock是Lock接口的实现类 Lock lock = new SomeLock(); // 抢锁时阻塞一段时间,如1s if(lock.tryLock(1, TimeUnit.SECONDS)){ try { // 抢锁成功,执行临界区代码 }finally { // 释放锁 lock.unlock(); } }else{ //抢锁失败,执行后续操作 }

对lock()、tryLock()、tryLock(long time,TimeUnit unit)这三个方法总结如下:

1)lock()方法用于阻塞抢锁,抢不到锁时线程会一直阻塞。

2)tryLock()方法用于尝试抢锁,该方法有返回值,如果成功就返回true,如果失败(锁已被其他线程获取)就返回false。此方法无论如何都会立即返回,在抢不到锁时,线程不会像调用lock()方法那样一直被阻塞。

3)tryLock(long time,TimeUnit unit)方法和tryLock()方法类似,只不过这个方法在抢不到锁时会阻塞一段时间。如果在阻塞期间获取到锁就立即返回true,超时则返回false。

40、ReentrantLock独占锁

ReentrantLock是JUC包提供的显式锁的一个基础实现类,ReentrantLock类实现了Lock接口,它拥有与synchronized相同的并发性和内存语义,但是拥有了限时抢占、可中断抢占等一些高级锁特性。此外,ReentrantLock基于内置的抽象队列同步器(Abstract Queued Synchronized,AQS)实现,在争用激烈的场景下,能表现出表内置锁更佳的性能。

ReentrantLock和Synchronize一样都是一个可重入的独占锁,其中两个修饰词的含义为:

可重入的含义:表示该锁能够支持一个线程对资源的重复加锁,也就是说,一个线程可以多次进入同一个锁所同步的临界区代码块。比如,同一线程在外层函数获得锁后,在内层函数能再次获取该锁,甚至多次抢占到同一把锁

ReentrantLock lock = new ReentrantLock(); // 第一次加锁 lock.lock(); // 第二次加锁 lock.lock(); try { }finally { // 释放锁 lock.unlock(); // 第二次释放锁 lock.unlock(); }

独占的含义:在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能等待,只有拥有锁的线程释放了锁后,其他的线程才能够获取锁。

@Slf4j public class TestLock { public static int sum = 0; @Test public void testReentrantLock(){ // 创建一个线程池,池中有10个线程 ExecutorService executorService = Executors.newFixedThreadPool(10); // 创建一个倒数闩 CountDownLatch countDownLatch = new CountDownLatch(10); //创建一个可重入、独占的锁对象 ReentrantLock lock = new ReentrantLock(); // 向线程池提交10个任务,10个线程同时执行 for(int i=0;i<10;i ){ executorService.submit(()-> { // 累加1000次 for(int j=0;j<1000;j ){ // 抢占锁 lock.lock(); try{ // 执行临界区代码 sum ; }finally { // 释放锁资源 lock.unlock(); } } System.out.println(Thread.currentThread().getName() "本线程累加完成"); // 线程执行完成,倒数闩减少一次 countDownLatch.countDown(); }); } try { // 等待倒数闩归零,所有线程结束 countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("累加结果:" IncrementData.sum); } }

除了具体可重入、独占特性之外,ReentrantLock还支持公平锁和非公平锁两种模式。

41、可中断锁与不可中断锁(超时锁等待)

什么是可中断锁?

如果某一线程A正占有锁在执行临界区代码,另一线程B正在阻塞式抢占锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己的阻塞等待,这种就是可中断锁。

什么是不可中断锁?

一旦这个锁被其他线程占有,如果自己还想抢占,只能选择等待或者阻塞,直到别的线程释放这个锁,如果别的线程永远不释放锁,那么自己只能永远等下去,并且没有办法终止等待或阻塞。简单来说,在抢锁过程中能通过某些方法终止抢占过程,这就是可中断锁,否则就是不可中断锁。Java的synchronized内置锁就是一个不可中断锁,而JUC的显式锁(如ReentrantLock)是一个可中断锁

可中断锁是指抢占过程可以被中断的锁,JUC的显式锁(如ReentrantLock)是一个可中断锁。不可中断锁是指抢占过程不可以被中断的锁,如Java的synchronized内置锁就是一个不可中断锁。

在JUC的显式锁Lock接口中,有以下两个方法可以用于可中断抢占:

1)lockInterruptibly()

可中断抢占锁抢占过程中会处理Thread.interrupt()中断信号,如果线程被中断,就会终止抢占并抛出InterruptedException 异常。

2)tryLock(long timeout,TimeUnit unit)

阻塞式“限时抢占”(在timeout时间内)锁抢占过程中会处理Thread.interrupt()中断信号,如果线程被中断,就会终止抢占并抛出InterruptedException 异常。

public class LockTest { private int sum; @Test public void testInterruptLock() throws InterruptedException { // 可重入锁 ReentrantLock lock = new ReentrantLock(); Runnable target = new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName() "开始抢占锁..."); try { lock.lockInterruptibly(); } catch (InterruptedException e) { System.out.println(Thread.currentThread().getName() "抢占被中断,抢锁失败..."); e.printStackTrace(); return; } try{ System.out.println(Thread.currentThread().getName() "抢到了锁,同步执行1秒"); // 线程处于阻塞状态 Thread.sleep(1000); sum ; if(Thread.currentThread().isInterrupted()){ System.out.println("同步被中断"); } } catch (Exception e) { e.printStackTrace(); }finally { lock.unlock(); } } }; Thread t1 = new Thread(target,"thread-1"); Thread t2 = new Thread(target,"thread-2"); t1.start(); t2.start(); Thread.sleep(100); System.out.println("等待100ms,中断两个线程"); // 中断两个线程 t1.interrupt(); t2.interrupt(); Thread.sleep(Integer.MAX_VALUE); } }

执行结果:

从该例子可以看出:

1)如果抢占过程收到由Thread.interrupt()方法发出的线程中断信号,lockInterruptibly()方法就会抛出InterruptedException。

2)如果此线程处于阻塞状态,就会立马退出阻塞,并抛出InterruptedException异常,线程就可以通过捕获InterruptedException来做一定的处理,然后让线程退出。更确切地说,如果线程被Object.wait()、Thread.join()和Thread.sleep()三种方法之一阻塞,此时调用该线程的interrupt()方法,该线程将抛出一个InterruptedException中断异常,从而提早终结被阻塞状态。

原文链接:https://blog.csdn.net/qq_42764468/article/details/126142553

,