一、Synchronized详解与锁升级

一、synchronized

1、共享问题

1、共享问题:在多线程的环境下,由于多个公共资源可能会被多个线程共享,也就是多个线程可能会操作(增、删、改等)同一资源。当多个线程操作同一资源时,很容易导致数据错乱,或发生数据安全问题(数据有可能丢失,有可能增加,有可能错乱)。
2、临界区:一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
3、解决共享问题方案:
  • 阻塞式:加锁,如:synchronized、Lock
  • 非阻塞式:原子变量

2、synchronized介绍

1、synchronized是Java中的关键字,是一种同步锁。在多线程开发中经常会使用到这个关键字,其主要作用是可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块,同时保证一个线程操作的数据变化被其他线程所看到。
2、synchronized实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
3、线程同步的两个条件:
  • 线程同步其实就是一种等待机制,多个需要同时访问对象的线程进入这个对象的等待池形成队列,等待前面的线程使用完毕后,下一个线程再使用。
  • 由于同一进程的多个线程共享一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制(synchronized),当一个线程获得对象的排它锁独占资源,其他的线程必须等待,使用后释放锁即可。
4、缺点:
  • 一个线程持有锁会导致其他所有需要此锁的线程挂起。
  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题。

3、synchronized三种应用方式

1、作用于普通方法(即实例方法),当前实例加锁,进入同步代码前要获得当前实例的锁,锁对象为当前实例对象
2、作用于代码块,对括号里配置的对象加锁,锁对象为synchronized括号里配置的对象
3、作用于静态方法,当前类加锁,进去同步代码块前要获得当前类对象的锁,锁对象为当前类的Class对象

4、synchronized常用情况分析

1、一个对象里面如果有多个同步方法,一个线程去调用其中一个同步方法,其他线程都不能调用其他同步方法,只能等待前面的调用结束,才能调用其他同步方法。此时锁的是当前实例对象(即new XXX())
/**
 * @Date: 2022/7/16
 * 验证一个线程先访问同步方法后,其他线程访问其他同步方法需要等待,
 * 说明锁的是当前对象this,也就是代码中的new User1()这个实例
 */
public class Sync1 {
    public static void main(String[] args) {
        User1 user = new User1();
        new Thread(() -> {
            user.getUser();
        }, "a").start();

        new Thread(() -> {
            user.getList();
        }, "b").start();
    }
}

/**
 * @Date: 2022/7/16
 * 资源类
 */
class User1 {
    protected Logger logger = LoggerFactory.getLogger(User1.class);

    public synchronized void getUser() {
        logger.info("进入getUser方法");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public synchronized void getList() {
        logger.info("进入getList方法");
    }
}
/**
 * 运行结果如下:先打印出getUser方法内容,3秒过后打印出getList方法内容,
 * 说明其他线程再去访问其他同步方法的时候需要等待
 * 16:58:52.830 [a] INFO com.itan.sync.User - 进入getUser方法
 * 16:58:55.848 [b] INFO com.itan.sync.User - 进入getList方法
 */
2、一个对象里面既有普通方法,也有同步方法的情况下,一个线程调用同步方法,其他线程还是可以调用普通方法,不会等待,与锁无关。
/**
 * @Date: 2022/7/16
 * 验证一个线程先访问同步方法,其他线程访问普通方法不需要等待
 */
public class Sync2 {
    public static void main(String[] args) {
        User2 user = new User2();
        new Thread(() -> {
            user.getUser();
        }, "a").start();

        new Thread(() -> {
            user.getOne();
        }, "b").start();
    }
}

/**
 * @Date: 2022/7/16
 * 资源类
 */
class User2 {
    protected Logger logger = LoggerFactory.getLogger(User2.class);

    public synchronized void getUser() {
        logger.info("getUser开始");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        logger.info("getUser结束");
    }

    public void getOne() {
        logger.info("进入普通方法getOne");
    }
}
/**
 * 运行结果如下:先打印同步方法内容:getUser开始,再立刻打印出普通方法内容,3s后同步方法执行结束
 * 17:12:38.678 [a] INFO com.itan.sync.User2 - getUser开始
 * 17:12:38.678 [b] INFO com.itan.sync.User2 - 进入普通方法getOne
 * 17:12:41.696 [a] INFO com.itan.sync.User2 - getUser结束
 */
3、一个对象里面都是静态的同步方法,某一时刻,只要有一个线程去调用其中一个静态同步方法了,其他的线程都不能进入到当前对象的其他静态同步方法。此时锁的是当前类的Class对象
/**
 * @Date: 2022/7/16
 * 验证静态同步方法,虽然每个线程使用不同new对象调用方法,但是只要有一个进入静态同步方法,
 * 其他线程都不能进入任何静态同步方法,因为锁对象为资源类的Class对象(即User3.Class)
 */
public class Sync3 {
    public static void main(String[] args) {
        //使用两个资源类,分别调用不同的方法
        User3 user = new User3();
        User3 user1 = new User3();
        new Thread(() -> {
            user.getUser();
        }, "a").start();

        new Thread(() -> {
            user1.getList();
        }, "b").start();
    }
}

/**
 * @Date: 2022/7/16
 * 资源类
 */
class User3 {
    protected static Logger logger = LoggerFactory.getLogger(User3.class);

    public static synchronized void getUser() {
        logger.info("getUser开始");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        logger.info("getUser结束");
    }

    public static synchronized void getList() {
        logger.info("getList开始");
    }
}
/**
 * 运行结果如下:先打印静态同步方法getUser,再打印出静态同步方法getList
 * 17:30:17.009 [a] INFO com.itan.sync.User3 - getUser开始
 * 17:30:20.019 [a] INFO com.itan.sync.User3 - getUser结束
 * 17:30:20.020 [b] INFO com.itan.sync.User3 - getList开始
 */
4、一个对象里面既有静态的同步方法,又有非静态的同步方法,一个线程去调用其中一个静态同步方法了,其他的线程都能调用其他非静态的同步方法。因为静态同步方法的锁是类对象本身(.Class),普通同步方法的锁是实例对象(new XXX())
/**
 * @Date: 2022/7/16
 * 验证既有静态同步方法,又有普通同步方法情况下,调用方法互不干扰,因为锁对象不同,
 * 静态同步方法锁对象是类Class对象,普通同步方法锁对象为实例对象(new XX())
 */
public class Sync4 {
    public static void main(String[] args) {
        //使用两个资源类,分别调用不同的方法
        User4 user = new User4();
        User4 user1 = new User4();
        new Thread(() -> {
            user.getUser();
        }, "a").start();

        new Thread(() -> {
            // user.getList();
            user1.getList();
        }, "b").start();
    }
}

/**
 * @Date: 2022/7/16
 * 资源类
 */
class User4 {
    protected static Logger logger = LoggerFactory.getLogger(User4.class);

    public static synchronized void getUser() {
        logger.info("getUser开始");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        logger.info("getUser结束");
    }

    public synchronized void getList() {
        logger.info("getList开始");
    }
}
/**
 * 运行结果如下:先打印静态同步方法内容:getUser开始,再立刻打印出普通同步方法内容,3s后静态同步方法执行结束
 * 17:46:36.166 [b] INFO com.itan.sync.User4 - getList开始
 * 17:46:36.166 [a] INFO com.itan.sync.User4 - getUser开始
 * 17:46:39.179 [a] INFO com.itan.sync.User4 - getUser结束
 */
5、总结:
  • 所有普通同步方法用的都是同一把锁——实例对象本身,也就是new XXX()的具体实例对象本身。也就是说如果一个实例对象的普通同步方法获取锁后,该实例的其他普通同步方法必须等待获取锁的方法释放了锁后才能获取锁。
  • 所有静态同步方法用的也是同一把锁——类对象本身,也就是XXX.Class。一旦一个静态同步方法获取锁后,其他静态同步方法都必须等待获取锁的静态同步方法释放了锁后才能获取锁。
  • 具体实例对象和类对象,这两把锁是不同的对象,所以静态同步方法与普通同步方法之间是不会等待的。
  • 若一个普通同步方法或静态同步方法获取锁后,调用其他普通方法是不会等待的。

5、从字节码角度分析synchronized实现

1、通过使用javap -c/v或jclasslib工具查看对应的字节码。
2、synchronized同步代码块对应的字节码:

在这里插入图片描述

3、synchronized普通同步方法对应的字节码:

在这里插入图片描述

说明:这段代码和普通的无同步操作的字节码没有什么区别,没有使用monitorenter和monitorexit进行同步区控制。这是因为,对于同步方法而言,当虚拟机通过方法的访问标示符判断是一个同步方法时,会自动在方法调用前进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这个锁。因此,对于同步方法而言,monitorenter和monitorexit指令是隐式存在的,并未直接出现在字节码中
4、synchronized静态同步方法对应的字节码:

在这里插入图片描述

6、Monitor原理

1、Monitors(管程,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
2、这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
3、每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。但当一个monitor被某个线程持有后,它便处于锁定状态
4、底层C中调用过程如下:ObjectMonitor.java->ObjectMonitor.cpp->objectMonitor.hpp,ObjectMonitor中有几个关键属性:
  • _owner:指向持有ObjectMonitor对象的线程ID
  • _WaitSet:存放处于wait状态的线程队列
  • _EntryList:存放处于等待锁block状态的线程队列
  • _recursions:锁的重入次数
  • _count:用来记录该线程获取锁的次数

在这里插入图片描述

5、参考:对象的内存布局
6、参考:同步控制指令内容

7、加锁解锁流程底层

指针指向monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,当一个monitor被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件由C++实现的)

在这里插入图片描述

二、对象的内存布局

1、概述

1、在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
2、如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

在这里插入图片描述

2、对象头(Header)

1、HotSpot虚拟机对象的对象头部分包括两类信息:运行时元数据(Mark Word)与类元信息(类型指针)
2、运行时元数据(Mark Word,针对64位虚拟机占用8字节)
  • 用于存储自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分的数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,称它为“Mark Word”。
  • 对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化
3、在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下图所示:

在这里插入图片描述

4、64位Mark Word存储内容如下(大小为64bit):

在这里插入图片描述

在这里插入图片描述

3、类型指针

1、类型指针大小为8个字节
2、对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。
3、如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小

4、实例数据(Instance Data)

1、它是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
2、这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
3、HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true)那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

5、对象填充(Padding)

1、它并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全

6、图解内存布局

public class CreateObject {
    int id = 1001;
    String name;
    Users users;

    {
        name = "匿名用户";
    }
    public CreateObject() {
        users = new Users();
    }
}

public class Users {
}

public class CreateObjTest {
    public static void main(String[] args) {
        CreateObject create = new CreateObject();
    }
}
内存布局图解:

在这里插入图片描述

7、使用JOL分析JVM中对象大小和布局

1、官网地址
2、依赖架包
<dependency>
 <groupId>org.openjdk.jol</groupId>
 <artifactId>jol-core</artifactId>
 <version>0.9</version>
</dependency>
/**
 * 普通使用
 * @Date: 2022/7/16
 */
public class JOLDemo {
    public static void main(String[] args) {
        // VM的细节详细情况
        System.out.println(VM.current().details());
        // 获取对象对齐方式(按8字节的倍数对齐),所有的对象分配的字节都是8的整数倍
        System.out.println(VM.current().objectAlignment());
    }
}
/**
 * 运行结果:
 * # Running 64-bit HotSpot VM.
 * # Using compressed oop with 3-bit shift.
 * # Using compressed klass with 3-bit shift.
 * # Objects are 8 bytes aligned.
 * # Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
 * # Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
 *
 * 8
 */
/**
 * new Object()的大小
 * @Date: 2022/8/16
 */
public class JOLDemo {
    public static void main(String[] args) {
        Object object = new Object();

        System.out.println(ClassLayout.parseInstance(object).toPrintable());
    }
}
1、上面代码运行结果如下:

在这里插入图片描述

2、属性解释:
属性名说明
OFFSET偏移量,也就是到这个字段位置所占用的byte数
SIZE后面类型的字节大小
TYPE是Class中定义的类型
DESCRIPTION类型的描述信息
VALUETYPE在内存中的值

8、对类型指针占用4字节的解释

1、设置JVM启动参数-XX:+PrintCommandLineFlags,运行上面程序,查看默认的参数设置

在这里插入图片描述

2、关闭-XX:-UseCompressedClassPointers,运行上面程序,查看对象大小和布局

在这里插入图片描述

3、结论:
  • 若启用了压缩类指针,一个普通对象的大小为8(MarkWorK) + 4(类型指针) + 4(对齐填充)= 16个字节
  • 若没有启用压缩类指针,一个普通对象的大小为8(MarkWorK) + 8(类型指针) = 16个字节

三、synchronized锁优化

1、概述

1、在Java SE 1.6里synchronized同步锁,一共有四种状态:无锁偏向锁轻量级锁重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
2、锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
3、synchronized锁由对象头中的Mark Word根据锁标志位的不同而被复用及锁升级策略
4、Monitor与Java对象以及线程的关联关系:
  • 如果一个Java对象被某个线程锁住,则该Java对象的Mark Word字段中的LockWord指向Monitor的起始地址。
  • Monitor的owner字段会存放拥有相关联对象的线程ID。

2、无锁

1、无锁:初始状态,一个对象被实例化后,如果还没有被任何线程竞争锁,那么它就为无锁状态(001)。

在这里插入图片描述

在这里插入图片描述

2、注意:只有调用了对象的hashCode方法,才会记录hash编码,否则无任何记录,如上图后31位全为0
/**
 * @Date: 2022/8/16
 * 无锁
 */
public class NoLock {
    public static void main(String[] args) {
        Object object = new Object();

        System.out.println(ClassLayout.parseInstance(object).toPrintable());
    }
}

3、偏向锁

1、大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程
2、当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要再次加锁和释放锁,而是直接检查对象头的Mark Word里是否存储着指向当前线程的ID
  • 如果Mark Word中的线程ID和当前线程ID相等,表示偏向锁是偏向于当前线程的,就不需要再尝试获取锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步,无需每次加锁解锁都去CAS更新对象头。如果始终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
  • 如果不相等,表示发生了竞争,锁已经不是总是偏向于同一个线程了,这个时候会尝试使用CAS来替换Mark Word中的线程ID为新线程的ID。
  • 竞争成功,表示之前的线程不存在了,Mark Word中的线程ID为新线程的ID,锁不会升级,仍然为偏向锁。
  • 竞争失败,这时候可能需要升级为轻量级锁,才能保证线程间公平竞争锁。
3、注意:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的
4、实现原理:一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word中去判断一下是否有偏向锁指向本身的ID,无需再进入Monitor去竞争对象了。
5、Java15逐步废弃偏向锁

4、偏向锁相关的JVM命令

1、偏向锁在JDK1.6之后是默认开启的,但是启动时间有延迟,默认延迟4秒后才会被激活,可以通过JVM参数来查看偏向锁相关的设置:java -XX:+PrintFlagsInitial |grep BiasedLock*

在这里插入图片描述

2、解决方法:
  • 设置线程休眠时间大于4秒,让其启动。
  • 设置启动参数-XX:BiasedLockingStartupDelay=0来禁用延迟,让其在程序启动时立刻启动。
3、开启关闭偏向锁命令:
  • 开启偏向锁:-XX:+UseBiasedLocking,JDK1.6之后默认开启
  • 关闭偏向锁:-XX:-UseBiasedLocking,关闭之后程序默认会直接进入轻量级锁
/**
 * @Date: 2022/8/16
 * 测试偏向锁,设置JVM启动参数禁用延迟:-XX:BiasedLockingStartupDelay=0
 */
public class BiasedLock {
    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println("synchronized前");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        synchronized (obj){
            System.out.println("synchronized中");
            // 偏向锁
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
        // 偏向锁
        System.out.println("synchronized后");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}
上面代码运行结果如下:锁状态为101(可偏向状态了),只是由于obj对象未用synchronized加锁,所以线程id是空的,全为0。但是在obj对象获取到锁之后,并且在退出同步块之后,线程id依然存在。

在这里插入图片描述

5、偏向锁的撤销

1、偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。
2、偏向锁的撤销需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还在执行(是否处于同步块中):
  • 第一个线程处于同步块,表示它还没有执行完,其他线程来抢夺,该偏向锁会被撤销并出现锁升级(到轻量级锁), 此时轻量级锁由原持有偏向锁的线程持有,继续执行同步代码块,而正在竞争的线程会进入自旋等待获得该轻量级锁。
  • 第一个线程退出同步块,表示它已经执行完成,则将对象头设置成无锁状态并撤销偏向锁,重新偏向后面的线程。

在这里插入图片描述

6、轻量级锁

1、轻量级锁是为了在线程近乎交替(线程1刚好执行完退出同步块时,线程2刚好要进入同步块)执行同步块时提高性能。
2、主要目的:在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,也就是先自旋再阻塞(升级为重量级锁)。
3、升级时机:当关闭偏向锁功能或多线程竞争偏向锁时,会导致偏向锁升级为轻量级锁
4、流程说明:
  • 假设线程A已经拿到锁,这时线程B来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已经是偏向锁了。而线程B在争抢时发现对象头MarkWord中的线程ID不是自己的线程ID(而是线程A的ID),那么线程B就会进行CAS操作希望能获得锁。
  • 如果线程B获得锁成功,直接替换MarkWord中的线程ID为自己的ID,重新偏向线程B,并保持偏向锁状态。
  • 如果线程B获得锁失败,则偏向锁升级为轻量级锁(设置偏向锁标识为0并设置锁标志位为00),此时轻量级锁由原持有偏向锁的线程(线程A)持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。

在这里插入图片描述

5、轻量级锁的加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
6、轻量级锁的释放:轻量级解锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果成功,则表示没有发生竞争。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

在这里插入图片描述

/**
 * @Date: 2022/8/16
 * 轻量级锁,设置JVM启动参数关闭偏向锁:-XX:-UseBiasedLocking
 */
public class LightWeightLock {
    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println("synchronized前");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        synchronized (obj){
            System.out.println("synchronized中");
            // 轻量级锁
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
        // 轻量级锁
        System.out.println("synchronized后");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}
运行结果如下:

在这里插入图片描述

7、轻量级锁CAS自旋次数

1、Java6之前:
  • 默认情况下自旋的次数是10次,可以通过-XX:PreBlockSpin=10参数自定义自旋次数
2、Java6之后:
  • 自适应次数,意味着自旋的次数不是固定不变的。如果线程自旋成功了,那么下次自旋的最大次数会增加,JVM认为既然上次成功了,那么这次也很大概率会成功。反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转。

8、轻量级锁与偏向锁区别

1、争夺轻量级锁失败时,自旋尝试抢占锁。
2、轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才会释放锁。

9、重量级锁

1、有大量的线程参与锁的竞争,冲突性很高。
2、重量级锁原理:
  • 是基于进入和退出Monitor对象实现的,在编译时会将同步块的开始位置插入monitor enter指令,在结束位置插入monitor exit指令。当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取成功,即获取锁成功,会在Monitor的owner中存放当前线程的ID,这样就处于锁定状态,除非退出同步块,否则其他先线程无法获取到这个Monitor。
/**
 * @Date: 2022/8/16
 * 重量级锁,多个线程抢锁
 */
public class HeavyWeightLock {
    public static void main(String[] args) {
        Object obj = new Object();
        new Thread(() -> {
            synchronized (obj){
                // 重量级锁
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "t1").start();
        new Thread(() -> {
            synchronized (obj){
                // 重量级锁
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "t2").start();
        new Thread(() -> {
            synchronized (obj){
                // 重量级锁
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        }, "t3").start();
    }
}
运行结果如下:

在这里插入图片描述

10、锁升级之后HashCode去向

1、锁升级为轻量级锁或重量级锁后,MarkWord中保存的分别是线程栈帧里的锁记录指针重量级锁指针,已经没有位置再保存哈希吗、GC年龄了。
2、锁与HashCode之间的关系:
  • 在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(也可以重载hashCode()方法按自己意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错的风险。
  • 而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码,这个能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。
  • 因此,当一个对象已经计算过一致性哈希码后,它就无法再进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁
  • 在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为01)下的MarkWord,其中自然可以存储原来的哈希码
3、总结说明:
  • 在无锁状态下,MarkWord中可以存储对象的一致性哈希码。当对象的hahsCode方法第一次被调用时,JVM会生成对应的一致性哈希码并将该值存储到MarkWord中。
  • 对于偏向锁,在线程获取偏向锁时,会用线程ID和Epoch值覆盖一致性哈希码所在的位置,如果一个对象的hashCode方法已经被调用过一次后,这个对象不能被设置偏向锁。如果可以的话,那MarkWord中的一致性哈希码必然会被偏向线程ID给覆盖,这就造成同一个对象前后两次调用hashCode方法得到的结果不一致。
  • 升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储对象的MarkWord拷贝,该拷贝中可以包含一致性哈希码,所以轻量级锁可以和一致性哈希码共存,GC年龄自然也可以保存,释放锁后会将这些信息写回到对象头。
  • 升级为重量级锁后,MarkWord保存的重量级锁指针,代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的MarkWord,锁释放后也会将信息写回到对象头。
/**
 * @Date: 2022/8/16
 * 验证当一个对象计算过hashcode时,它就无法进入到偏向锁状态,
 * 跳过偏向锁,直接升级为轻量级锁
 */
public class LockTest {
    public static void main(String[] args) throws InterruptedException {
        // 休眠5s,保证开启偏向锁,也可以使用启动参数-XX:BiasedLockingStartupDelay=0来禁用延迟
        Thread.sleep(5000);
        Object obj = new Object();
        System.out.println("偏向锁信息:");
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());

        // 计算hashCode,它就无法进入偏向锁状态了
        System.out.println("hashCode值:" + obj.hashCode());
        synchronized (obj){
            System.out.println("轻量级锁信息:");
            // 轻量级锁
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}
运行结果如下:本应是偏向锁,由于计算了hashCode之后,直接升级为轻量级锁

在这里插入图片描述

/**
 * @Date: 2022/8/16
 * 验证在偏向锁状态,遇到一致性哈希码计算的请求时
 * 立马撤销偏向锁,膨胀为重量级锁
 */
public class LockTest1 {
    public static void main(String[] args) throws InterruptedException {
        // 休眠5s,保证开启偏向锁,也可以使用启动参数-XX:BiasedLockingStartupDelay=0来禁用延迟
        Thread.sleep(5000);
        Object obj = new Object();

        synchronized (obj){
            System.out.println("计算hashCode之前:偏向锁");
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            // 计算hashCode,它就无法进入偏向锁状态了
            System.out.println("hashCode值:" + obj.hashCode());
            System.out.println("计算hashCode之后:重量级锁");
            // 轻量级锁
            System.out.println(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}
运行结果如下:本应是偏向锁,由于计算了hashCode之后,直接升级为轻量级锁

在这里插入图片描述

11、几种锁的优缺点对比

在这里插入图片描述

12、JIT编译器对锁的优化之锁消除

1、锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享 数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持(参考逃逸分析相关介绍),如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
public String concatString(String s1, String s2, String s3) {
	return s1 + s2 + s3;
}
由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK5之前,字符串加法会转化为StringBuffer对象的连续append()操作,在JDK5及以后的版本中,会转化为StringBuilder对象的连续append()操作。
public String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}
每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,经过逃逸分析后会发现它的动态作用域被限制在concatString()方法内部。也就是sb的所有引用都永远不会逃逸到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步操作而直接执行。

13、JIT编译器对锁的优化之锁粗化

1、原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。
2、大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
3、上面代码中所示连续的append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上面为例,就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可 以了。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值