【Java并发编程 】同步——synchronized 关键字

一、synchronized 的介绍?

/'sɪŋkrənaɪzd 星可奶(一直记不住这个单词怎么读)

1. synchronized 是什么

Java平台中任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器或者内部锁(Intrinsic Lock)。内部锁是一种排他锁,它能够保障原子性、可见性和有序性。在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁是依赖于底层的操作系统Mutex Lock 来实现的,Java的线程池是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程直接的切换时,需要从用户态转换到内核态,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。

JDK1.6对锁引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

内部锁是通过synchronized 关键字实现的。synchronized 关键字可以用来修饰方法以及代码块

synchronized 关键字修饰的方法就称为同步方法
synchronized 关键字修饰的静态方法就被称为同步静态方法
synchronized 关键字修饰的实例方法就被称为同步实例方法

同步方法的整个方法体就是一个临界区。

2. 为什么要用synchronized

在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的

3. synchronized关键字三大特性是什么?

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

4. synchronized关键字可以实现什么类型的锁?

  • 悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
  • 非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
  • 可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
  • 独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

二、synchronized关键字的使用方法

synchronized主要有三种使用方式:

  • 修饰普通同步方法
  • 修饰静态同步方法
  • 修饰同步方法块

1. 修饰普通同步方法

修饰普通同步方法

  • 保证使用同一个对象锁——即新建 对象锁,放入到多线程执行。
  • 保证多线程调用的方法是同步的
/**
 * @Author: LiangYiFeng
 * @Description: 修饰普通方法
 * @Date: Create in 2022/8/16 11:18
 * @Modified By:
 */
public class SyncTest implements Runnable {

    private static int i = 0; //共享资源


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

    public  void run() {
        for (int j =0 ; j<10000; j++){
            add();
        }
    }


    public static void main(String[] args) throws InterruptedException {
        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);

    }
}

输出结果:

20000

2. 线程不安全示例

若两次new syncTest()操作建立的是两个不同的对象:则会出现线程不安全

  • 虽然有同步方法,但是有两个对象锁——依然导致线程不安全
/**
 * @Author: LiangYiFeng
 * @Description: 建立的是两个不同的对象
 * @Date: Create in 2022/8/16 11:18
 * @Modified By:
 */
public class SyncTest implements Runnable {

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

输出结果:出现意想不到的结果——线程不安全导致

19355

因为两次new syncTest()操作建立的是两个不同的对象,也就是说存在两个不同的对象锁,线程t1和t2使用的是不同的对象锁,所以不能保证线程安全。

那这种情况应该如何解决呢?

因为每次创建的实例对象都是不同的,而类对象却只有一个,如果synchronized关键字作用于类对象,即用synchronized修饰静态方法,问题则迎刃而解。

3. 修饰静态同步方法

修饰静态同步方法
只需要在add()方法前用static修饰即可,即当synchronized作用于静态方法,锁就是当前的class对象。

  • static+ synchronized : 形成静态同步方法,两个锁对象——也能保持线程对象
/**
 * @Author: LiangYiFeng
 * @Description: 修饰静态同步方法
 * @Date: Create in 2022/8/16 11:18
 * @Modified By:
 */
public class SyncTest implements Runnable {

    private static int i = 0; //共享资源
    private static synchronized void add() {
        i++;
    }

    public void run() {
        for (int j =0 ; j<10000; j++){
            add();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new SyncTest());//调用方法时,是两个不同对象,同时也是锁当前对象
        Thread t2 = new Thread(new SyncTest());//所以线程是安全的
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

输出结果:

20000

4. 修饰同步方法块

修饰同步方法块

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

  • 优化代码块——提升代码性能
  • 使用同一个锁对象——保证线程安全
/**
 * @Author: LiangYiFeng
 * @Description: 修饰同步方法块
 * @Date: Create in 2022/8/16 11:18
 * @Modified By:
 */
public class SyncTest implements Runnable {

    static int i = 0; //共享资源
    public  void run() {

        synchronized (SyncTest.class){	//this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
            for (int j =0 ; j<10000; j++){
                    i++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        SyncTest syncTest = new SyncTest(); // new
        Thread t1 = new Thread(syncTest);
        Thread t2 = new Thread(syncTest);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);

    }
}

三、synchronized 底层实现原理

1.synchronized 底层实现原理

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

在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头、实例数据和填充对齐。因为synchronized用的锁是存在对象头里的,这里我们需要重点了解对象头。如果对象头是数组类型,则对象头由Mark Word、Class MetadataAddress和Array length组成,如果对象头非数组类型,对象头则由Mark Word和Class MetadataAddress组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:

内容说明长度
Mark Word存储对象的hashCode、分代年龄和锁标记位32bit
Class MetadataAddress存储到对象类型数据的指针32bit
Array length数组的长度32bit

这里我们需要重点掌握的是:Mark Word 在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:
在这里插入图片描述

其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的。

  • 重量级锁的底部实现原理:Monitor

在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的,首先我们先下载Hotspot的源码,源码下载链接:http://hg.openjdk.java.net/jdk8/jdk8/hotspot,
找到ObjectMonitor.hpp文件,路径是src/share/vm/runtime/objectMonitor.hpp,这里只是简单介绍下其数据结构。

ObjectMonitor() { 
		_header = NULL; 
		_count = 0; //锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到 
		_waiters = 0, //等待线程数 
		_recursions = 0; //锁的重入次数 
		_object = NULL; _owner = NULL; //指向持有ObjectMonitor对象的线程地址 
		_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet 
		_WaitSetLock = 0 ; 
		_Responsible = NULL ; 
		_succ = NULL ; 
		_cxq = NULL ; //阻塞在EntryList上的单向线程列表 
		FreeNext = NULL ; 
		_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
		_SpinFreq = 0 ; 
		_SpinClock = 0 ; 
		OwnerIsThread = 0 ; 
}

其中 _owner、_WaitSet和_EntryList 字段比较重要,它们之间的转换关系如下图
在这里插入图片描述
从上图可以总结获取Monitor和释放Monitor的流程如下:

  1. 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。
  2. 当获取锁的线程调用wait()方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。
  3. 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁。

2. synchronized作用于同步代码块的实现原理

前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。
synchronized是Java中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过javap命令,查看相应的字节码文件
synchronized 同步语句块的情况:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}
target文件下:执行命令
javap -c -v SynchronizedDemo.class

在这里插入图片描述
从上述字节码中可以看到同步代码块的实现是由monitorentermonitorexit 指令完成的,

  • 其中monitorenter指令所在的位置是同步代码块开始的位置
  • 第一个monitorexit 指令是用于正常结束同步代码块的指令
  • 第二个monitorexit 指令是用于异常结束时所执行的释放Monitor指令。

主要过程

  • 首先要获取锁,而获取锁的过程就是monitorenter
  • 在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。
  • 最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁

为什么会有两个monitorexit呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。

  • Synchronized 的语义底层是通过一个monitor (监视器锁)的对象来完成的
  • 每个对象有一个监视器锁(monitor)。每个Synchronized 修饰过的代码当它的monitor 被占用时就会处于锁定状态并且尝试获取monitor的所有权,
    1. 如果monitor的进入数为0,则该线程池进入monitor,然后将进入数设置为1,该线程即为monitor的所有者
    2. 如果线程已经占有该monitor,只是重新进入,则进入monitor得到进入数加1
    3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权

3. synchronized作用于同步方法原理

	private synchronized void add(){
		i++;

通过javap 反编译,查看字节码文件:
target文件下:执行命令
javap -c -v SynchronizedDemo2.class

public class SynchronizedDemo2 {
    private int i =0;
    public synchronized void add() {
        i++;
    }

    public static void main(String[] args) {
        SynchronizedDemo2 synchronizedDemo = new SynchronizedDemo2();
        synchronizedDemo.add();
    }
}

在这里插入图片描述

发现这个没有monitorenter 和 monitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags后边的acc_synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。
在这里插入图片描述

原理大概就是这样,最后总结一下,面试中应该简洁地如何回答synchroized的底层原理这个问题。

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

四、 jdk1.6 synchronized优化?

1.为什么要优化

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

2.锁的升级

在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,无锁状态,偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级

在这里插入图片描述

1)锁升级的目的

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

2)锁升级的原理

在这里插入图片描述

synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与当前线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

3.偏向锁

引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁。

偏向锁的获取流程:

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

偏向锁的获取流程如下图:
在这里插入图片描述

偏向锁的撤销:
只有等到竞争,持有偏向锁的线程才会撤销偏向锁。偏向锁撤销后会恢复到无锁或者轻量级锁的状态。

  1. 偏向锁的撤销需要到达全局安全点,全局安全点表示一种状态,该状态下所有线程都处于暂停状态。
  2. 判断锁对象是否处于无锁状态,即获得偏向锁的线程如果已经退出了临界区,表示同步代码已经执行完了。重新竞争锁的线程会进行CAS操作替代原来线程的ThreadID。
  3. 如果获得偏向锁的线程还处于临界区之内,表示同步代码还未执行完,将获得偏向锁的线程升级为轻量级锁。

一句话简单总结偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中。

4.轻量级锁

1)引入目的

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

2)实现原理

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,轻量级锁就会升级为重量级锁。

  • 轻量级锁的获取流程:首先判断当前对象是否处于一个无锁的状态,如果是,Java虚拟机将在当前线程的栈帧建立一个锁记录(Lock Record),用于存储对象目前的Mark Word的拷贝,如图所示。
    在这里插入图片描述
  • 将对象的Mark Word复制到栈帧中的Lock Record中,并将LockRecord中的owner指向当前对象,并使用CAS操作将对象的Mark Word更新为指向Lock Record的指针,如图所示。
    在这里插入图片描述
  • 如果第二步执行成功,表示该线程获得了这个对象的锁,将对象Mark Word中锁的标志位设置为“00”,执行同步代码块。
  • 如果第二步未执行成功,需要先判断当前对象的Mark Word是否指向当前线程的栈帧,如果是,表示当前线程已经持有了当前对象的锁,这是一次重入,直接执行同步代码块。如果不是表示多个线程存在竞争,该线程通过自旋尝试获得锁,即重复步骤2,自旋超过一定次数,轻量级锁升级为重量级锁。

3)解锁

轻量级锁的解锁:
轻量级的解锁同样是通过CAS操作进行的,线程会通过CAS操作将Lock Record中的Mark Word(官方称为Displaced Mark Word)替换回来。如果成功表示没有竞争发生,成功释放锁,恢复到无锁的状态;如果失败,表示当前锁存在竞争,升级为重量级锁。

一句话总结轻量级锁的原理:将对象的Mark Word复制到当前线程的Lock Record中,并将对象的Mark Word更新为指向Lock Record的指针。

5.自旋锁

Java锁的几种状态并不包括自旋锁,当轻量级锁的竞争就是采用的自旋锁机制。

1)什么是自旋锁

很多 synchronized 里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内核态切换的问题。既然 synchronized 里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在 synchronized 的边界做忙循环,这就是自旋。如果做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略

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

  • 忙循环:就是程序员用循环让一个线程等待,不像传统方法wait(),sleep()或yield()它们都放弃了CPU控制,而忙循环不会放弃CPU,它就是在运行一个空循环。这么做的目的是为了保留CPU缓存,在多核系统中,一个等待线程醒来的时候可能会在另一个内核运行,这样会重建缓存。为了避免重建缓存和减少等待重建的世界就可以使用它了。

2)为什么引入

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

3)自旋锁的特点

自旋锁的缺点:自旋等待虽然可以避免线程切花的开销,但它也会占用处理器的时间。如果持有锁的线程在较短的时间内释放了锁,自旋锁的效果就比较好,如果持有锁的线程很长时间都不释放锁,自旋的线程就会白白浪费资源,所以一般线程自旋的次数必须有一个限制,该次数可以通过参数-XX:PreBlockSpin调整,一般默认为10。

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

6. 重量级锁

重量级锁是synchronized, 是Java虚拟机中最为基础的锁实现。在这种状态下,Java虚拟机会阻塞加锁失败的线程池,并且在目标锁被释放的时候,唤醒这些线程。

7. 偏向锁、轻量级锁、重量级锁的区别

优点缺点实用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在竞争,会额外带来锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较慢

8. 锁消除、锁粗化

锁消除
锁消除是指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。

锁粗化
一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。

如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。可以看下面这个经典案例。

	for(int i=0;i<n;i++){
		synchronized(lock){
		}
	}

这段代码会导致频繁地加锁和解锁,锁粗化后

	synchronized(lock){
		for(int i=0;i<n;i++){
		}
	}

五、说说自己是怎么使用 synchronized 关键字,在项目中用到了吗

synchronized关键字最主要的三种使用方式:

  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!

1. 单例模式了解吗?给我解释一下双重检验锁方式实现单例模式!”

下面我以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理!”

双重校验锁实现对象单例(线程安全)

public class Singleton {

    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。

例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

2. 当一个线程进入一个对象的 synchronized 方法 A 之后,其它线程是否可进入此对象的 synchronized 方法 B?

不能,线程2只能访问该对象的非同步方法。因为执行同步方法时需要获得对象的锁,而线程1在进入sychronized修饰的方A时已经获取到了锁,线程2只能等待,无法进入到synchronized修饰的方法B,但可以进入到其他非synchronized修饰的方法。

六、synchronized和volatile区别

  • volatile主要是保证内存的可见性,即变量在寄存器中的内存是不确定的,需要从主存中读取。synchronized主要是解决多个线程访问资源的同步性。
  • volatile作用于变量,synchronized作用于代码块或者方法。
  • volatile仅可以保证数据的可见性,不能保证数据的原子性。synchronized可以保证数据的可见性和原子性。
  • volatile不会造成线程的阻塞,synchronized会造成线程的阻塞。

七、synchronized 和 Lock 有什么区别?

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

八、synchronized 和 ReentrantLock 区别是什么?

  • synchronized 是和 if、else、for、while 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量
  • synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大,但是在 Java 6 中对 synchronized 进行了非常多的改进。

相同点:

  • 两者都是可重入锁

“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

主要区别如下:

  • ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
  • ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
  • ReentrantLock 只适用于代码块锁,而 synchronized 可以修饰类、方法、变量等。
  • 二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的class对象
  • 同步方法块,锁是括号里面的对象

九、Synchronized面试题

Synchronized 相 关 问 题

问题一 : Synchronized用过吗 , 其原理是什么?

这个问题要从两个角度看:

  • jdk1.6前:重量级锁
  • jdk1.6后:偏向锁和轻量级锁

首先jdk1.6前——synchronized只能实现重量级锁,其实现原理:
Java虚拟机是基于Monitor对象来实现重量级锁的。所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的。如果你使用javap 反编译查看字节码文件, 可以看出被Synchronized修饰过的程序块编译后的字节码,会发现被Synchronized修饰过的程序块,在编译前后被编译器生成了monitorentermonitorexit两个字节码指令。

这两个指令是什么意思呢?

  • 当虚拟机执行到monitorenter指令时,首先要尝试获取对象的锁:如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1,锁的可重入性+1;
  • 当执行monitorexit指令时将锁计数器-1,锁的可重入性-1;当锁的可重入性为0时,锁就被释放了。如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

因为在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头、实例数据和填充对齐。因为Synchronized用的锁是存在对象头里的,所以Synchronized通过在对象头设置标记,达到了获取锁和释放锁的目的。

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

问题二:什么是对象锁? 如何确定对象锁?

“锁”的本质其实是monitorenter和monitorexit字节码指令的一个Reference类型的参数,即要锁定和解锁的对象。我们知道,使用Synchronized可以修饰不同的对象,因此,对应的对象锁可以这么确定:

  • 如果Synchronized明确指定了锁对象,比如Synchronized(变量名)、Synchronized(this)等,说明加解锁对象为该对象
  • 如果Synchronized没有明确指定
    • 若Synchronized修饰的方法为非静态方法,表示此方法对应的对象为锁对象;
    • 若Synchronized修饰的方法为静态方法,则表示此方法对应的类对象为锁对象。
    • 注意,当一个对象被锁住时,对象里面所有用Synchronized修饰的方法都将产生堵塞,而对象里非Synchronized修饰的方法可正常被调用,不受锁影响。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值