一、沉默王二-并发编程
1、原子操作类Atomic
Java 中的原子操作类,如 AtomicInteger 和 AtomicLong,底层就是利用 CAS 来确保变量更新的原子性的。
像递增运算 count++ 就不是一个原子操作,在多线程环境下并不能得到正确的结果,因为 count++ 操作实际上分为三个步骤:
- 读取 count 变量的值;
- 将 count 变量的值加 1;
- 将 count 变量的值写入到内存中;
假定线程 A 正在修改 count 变量,为了保证线程 B 在使用 count 的时候是线程 A 修改过后的状态,可以用 synchronized 关键字同步一手。
private long count = 0;
public synchronized void write() {
System.out.println("我寻了半生的春天,你一笑便是了。");
count++;
}
但多个线程之间访问 write() 方法是互斥的,线程 B 访问的时候必须要等待线程 A 访问结束,有没有更好的办法呢?
AtomicInteger 是 JDK 提供的一个原子操作的 Integer 类,它提供的加减操作是线程安全的。于是我们可以这样:
private AtomicInteger count = new AtomicInteger(0);
public void write() {
System.out.println("我寻了半生的春天,你一笑便是了。");
count.incrementAndGet();
}
你看,这下是不是就舒服多了,不用加锁,也能保证线程安全。OK,接下来,我们来看看原子操作类都有哪些?
1.1 原子操作的基本数据类型
基本类型的原子操作主要有这些:
- AtomicBoolean:以原子更新的方式更新 boolean;
- AtomicInteger:以原子更新的方式更新 Integer;
- AtomicLong:以原子更新的方式更新 Long;
这几个类的用法基本一致,这里以 AtomicInteger 为例。
addAndGet(int delta):增加给定的 delta,并获取新值。incrementAndGet():增加 1,并获取新值。getAndSet(int newValue):获取当前值,并将新值设置为 newValue。getAndIncrement():获取当前值,并增加 1。
1.1.1 AtomicInteger
为了能够弄懂 AtomicInteger 的实现原理,以 getAndIncrement 方法为例,来看下源码:
/**
* 原子地增加AtomicInteger的当前值,并返回增加前的原始值。
* 使用Unsafe类中的getAndAddInt方法实现。
*
* @return 返回增加前的原始值。
*/
public final int getAndIncrement() {
// 使用Unsafe类中的getAndAddInt方法原子地增加AtomicInteger的当前值
// 第一个参数this是AtomicInteger的当前实例
// 第二个参数valueOffset是一个偏移量,它指示在AtomicInteger对象中的哪个位置可以找到实际的int值
// 第三个参数1表示要加到当前值上的值(即增加的值)
// 此方法返回的是增加前的原始值
return unsafe.getAndAddInt(this, valueOffset, 1);
}
可以看出,该方法实际上是调用了 unsafe 对象的 getAndAddInt 方法,unsafe 对象是通过通过 UnSafe 类的静态方法 getUnsafe 获取的:
private static final Unsafe unsafe = Unsafe.getUnsafe();
Unsafe 类我们在讲 CAS 的时候也讲过,包括 AtomicInteger 类,相信大家还有印象。
Unsafe 类是 Java 中的一个特殊类,用于执行低级、不安全的操作。getAndIncrement 方法就是利用了 Unsafe 类提供的 CAS(Compare-And-Swap)操作来实现原子的 increment 操作。CAS 是一种常用的无锁技术,允许在多线程环境中原子地更新值。
好,下面用一个简单的例子来说明 AtomicInteger 的用法:
public class AtomicDemo {
private static AtomicInteger atomicInteger = new AtomicInteger(1);
public static void main(String[] args) {
System.out.println(atomicInteger.getAndIncrement());
System.out.println(atomicInteger.get());
}
}
输出结果:
1
2
1.1.2 AtomicLong
AtomicLong 和 AtomicInteger 的实现原理基本一致,只不过一个针对的是 long 型,一个针对的是 int 型。
AtomicBoolean 类是怎样实现更新的呢?核心方法是compareAndSet 方法,其源码如下:
public final boolean compareAndSet(boolean expect, boolean update) {
// 将expect布尔值转化为整数,true为1,false为0
int e = expect ? 1 : 0;
// 将update布尔值转化为整数,true为1,false为0
int u = update ? 1 : 0;
// 使用Unsafe类中的compareAndSwapInt方法尝试原子地更新AtomicBoolean的当前值
// 第一个参数this是AtomicBoolean的当前实例
// 第二个参数valueOffset是一个偏移量,
// 它指示在AtomicBoolean对象中的哪个位置可以找到实际的int值
// 第三个参数e是我们期望的当前值(转换为整数后的值)
// 第四个参数u是我们想要更新的值(转换为整数后的值)
// 如果当前值与期望值e相等,它会被原子地设置为u,并返回true;否则返回false。
return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}
该方法尝试将当前值从expect设置为update,但这种设置只会在当前值确实为expect时成功。方法返回true表示更新成功,否则返回false。
1.2 原子操作的数组类型
如果需要原子更新数组里的某个元素,atomic 也提供了相应的类:
- AtomicIntegerArray:这个类提供了一些原子更新 int 整数数组的方法。
- AtomicLongArray:这个类提供了一些原子更新 long 型证书数组的方法。
- AtomicReferenceArray:这个类提供了一些原子更新引用类型数组的方法。
这几个类的用法一致,就以 AtomicIntegerArray 来总结下常用的方法:
addAndGet(int i, int delta):以原子更新的方式将数组中索引为 i 的元素与输入值相加;getAndIncrement(int i):以原子更新的方式将数组中索引为 i 的元素自增加 1;compareAndSet(int i, int expect, int update):将数组中索引为 i 的位置的元素进行更新
可以看出,AtomicIntegerArray 与 AtomicInteger 的方法基本一致,只不过在 AtomicIntegerArray 的方法中会多一个数组索引 i。下面举一个简单的例子:
public class AtomicDemo {
// 定义一个整型数组,初始值为{1, 2, 3}
private static int[] value = new int[]{1, 2, 3};
// 使用AtomicIntegerArray包装整型数组,以支持原子操作
private static AtomicIntegerArray integerArray = new AtomicIntegerArray(value);
public static void main(String[] args) {
// 对数组中索引为1的位置的元素加5,并返回修改前的值
int result = integerArray.getAndAdd(1, 5);
// 打印修改后索引为1的位置的元素值
System.out.println(integerArray.get(1));
// 打印修改前的值
System.out.println(result);
}
}
输出结果:
7
2
通过 getAndAdd 方法将位置为 1 的元素加 5,从结果可以看出索引为 1 的元素变成了 7,该方法返回的也是相加之前的数为 2。
1.3 原子操作的引用类型
如果需要原子更新引用类型的话,atomic 也提供了相关的类:
- AtomicReference:原子更新引用类型;
- AtomicReferenceFieldUpdater:原子更新引用类型里的字段;
- AtomicMarkableReference:原子更新带有标记位的引用类型;
这几个类的使用方法也是基本一样,以 AtomicReference 为例,来说明这些类的基本用法。
// 定义一个使用原子引用的示例类AtomicDemo
public class AtomicDemo {
// 创建一个原子引用,用于存储User对象
private static AtomicReference<User> reference = new AtomicReference<>();
// 主方法,程序的入口
public static void main(String[] args) {
// 创建一个User对象,名字为"a",年龄为1
User user1 = new User("a", 1);
// 将user1设置为原子引用的当前值
reference.set(user1);
// 创建另一个User对象,名字为"b",年龄为2
User user2 = new User("b",2);
// 获取原子引用的当前值,并设置新的值user2,返回旧的值
User user = reference.getAndSet(user2);
// 打印返回的旧值
System.out.println(user);
// 打印原子引用的当前新值
System.out.println(reference.get());
}
// 定义一个内部静态类User
static class User {
// 用户名
private String userName;
// 年龄
private int age;
// User类的构造方法,接收用户名和年龄作为参数
public User(String userName, int age) {
this.userName = userName;
this.age = age;
}
// 重写toString方法,用于返回User对象的字符串表示
@Override
public String toString() {
return "User{" +
"userName='" + userName + '\'' +
", age=" + age +
'}';
}
}
}
输出结果:
User{userName='a', age=1}
User{userName='b', age=2}
首先将对象 User1 用 AtomicReference 进行封装,然后调用 getAndSet 方法进行赋值,从结果可以看出,该方法会原子更新 user 对象,变为 User{userName='b', age=2}。
1.4 原子更新字段类型
如果需要更新对象的某个字段,atomic 同样也提供了相应的原子操作类:
- AtomicIntegeFieldUpdater:原子更新整型字段类;
- AtomicLongFieldUpdater:原子更新长整型字段类;
- AtomicStampedReference:原子更新引用类型,这种更新方式会带有版本号,是为了解决 CAS 的 ABA 问题,ABA 问题我们前面也讲过。
使用原子更新字段需要两步:
- 通过静态方法
newUpdater创建一个更新器,并且设置想要更新的类和字段; - 字段必须使用
public volatile进行修饰;
以 AtomicIntegerFieldUpdater 为例来看看具体的使用:
// 定义一个原子操作类AtomicDemo
public class AtomicDemo {
// 创建一个AtomicIntegerFieldUpdater,用于原子更新User类中的age字段
private static AtomicIntegerFieldUpdater<User> updater =
AtomicIntegerFieldUpdater.newUpdater(User.class,"age");
// 主方法,程序入口
public static void main(String[] args) {
// 创建一个User对象,姓名为"a",年龄为1
User user = new User("a", 1);
// 原子操作:获取user对象的age字段值,并在此基础上增加5,返回增加前的值
int oldValue = updater.getAndAdd(user, 5);
// 打印增加前的值
System.out.println(oldValue);
// 打印增加后的值
System.out.println(updater.get(user));
}
// 定义一个静态内部类User
static class User {
// 定义用户名
private String userName;
// 定义年龄,使用volatile关键字保证可见性
public volatile int age;
// User类的构造方法
public User(String userName, int age) {
this.userName = userName;
this.age = age;
}
// 重写toString方法,用于返回User对象的字符串表示
@Override
public String toString() {
return "User{" +
"userName='" + userName + '\'' +
", age=" + age +
'}';
}
}
}
输出结果:
1
6
从示例中可以看出,创建AtomicIntegerFieldUpdater是通过它提供的静态方法进行创建的,getAndAdd方法会将指定的字段加上输入的值,并返回相加之前的值。user 对象中 age 字段原值为 1,加 5 之后变成了 6。
2、魔法类 Unsafe
Unsafe 是 Java 中一个非常特殊的类,它为 Java 提供了一种底层、"不安全"的机制来直接访问和操作内存、线程和对象。正如其名字所暗示的,Unsafe 提供了许多不安全的操作,因此它的使用应该非常小心,并限于那些确实需要使用这些底层操作的场景。
2.1 Unsafe 基础
首先我们来尝试获取一个 Unsafe 实例,如果按照new的方式去创建,不好意思,编译器会直接报错:
Unsafe() has private access in 'sun.misc.Unsafe'
查看 Unsafe 类的源码,可以发现它是被 final 修饰的,所以不允许被继承,并且构造方法为private类型,即不允许我们直接 new 实例化。不过,Unsafe 在 static 静态代码块中,以单例的方式初始化了一个 Unsafe 对象:
// 定义一个名为Unsafe的最终类,表示这个类不能被继承
public final class Unsafe {
// 声明一个静态且最终的Unsafe类型变量theUnsafe
private static final Unsafe theUnsafe;
// 私有构造方法,防止外部类创建该类的实例
private Unsafe() {
}
// 静态代码块,在类加载时执行,用于初始化静态变量theUnsafe
static {
theUnsafe = new Unsafe();
}
}
Unsafe 类提供了一个静态方法getUnsafe,看上去貌似可以用它来获取 Unsafe 实例:
// @CallerSensitive 注解用于指示该方法需要知道调用者的信息
@CallerSensitive
public static Unsafe getUnsafe() {
// 获取调用该方法的类的Class对象
Class var0 = Reflection.getCallerClass();
// 检查调用者的类加载器是否为系统类加载器
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
// 如果不是系统类加载器,抛出SecurityException异常
throw new SecurityException("Unsafe");
} else {
// 如果是系统类加载器,返回Unsafe实例
return theUnsafe;
}
}
但是如果我们直接调用这个静态方法,也会抛出异常:
Exception in thread "main" java.lang.SecurityException: Unsafe
at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12)
这是因为在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话就会抛出一个SecurityException异常。
也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,这是为了防止这些方法在不可信的代码中被调用。
那么,为什么要对 Unsafe 类进行这么谨慎的使用限制呢?
说到底,还是因为它实现的功能过于底层,例如直接进行内存操作、绕过 jvm 的安全检查创建对象等等,概括的来说,Unsafe 类实现的功能可以被分为下面 8 类:
2.1.1 创建实例
看到上面这些功能,你是不是已经有些迫不及待想要试一试了?
那么如果我们执意想要在自己的代码中调用 Unsafe 类的方法,应该怎么获取一个它的实例对象呢?
答案是利用反射获得 Unsafe 类中已经实例化完成的单例对象:
/**
* 获取Unsafe实例的方法。
* Unsafe类是一个特殊的类,提供了一些可以直接操作内存和线程的低层次操作。
* 这个方法用于获取Unsafe类的实例。
*
* @return Unsafe类的实例
* @throws IllegalAccessException 如果没有权限访问Unsafe类
*/
public static Unsafe getUnsafe() throws IllegalAccessException {
// 获取Unsafe类中的名为"theUnsafe"的字段
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
// Field unsafeField = Unsafe.class.getDeclaredFields()[0]; // 也可以这样,作用相同
// 设置该字段为可访问,即使它是私有的
unsafeField.setAccessible(true);
// 获取该字段的值,由于它是静态的,所以传递null作为对象参数
Unsafe unsafe = (Unsafe) unsafeField.get(null);
// 返回获取到的Unsafe实例
return unsafe;
}
在获取到 Unsafe 的实例对象后,我们就可以使用它来为所欲为了,先来尝试使用它对一个对象的属性进行读写:
/**
* 使用Unsafe类直接操作对象字段的方法。
* Unsafe类是一个提供低级别内存操作和并发控制功能的类,通常用于底层编程。
*
* @param unsafe Unsafe类的实例,用于执行底层操作。
* @throws NoSuchFieldException 如果指定的字段不存在。
*/
public void fieldTest(Unsafe unsafe) throws NoSuchFieldException {
// 创建User对象
User user = new User();
// 获取User类中名为"age"的字段的偏移量
// objectFieldOffset方法返回指定字段的内存偏移量,用于直接访问该字段
long fieldOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("age"));
// 打印字段的偏移量
System.out.println("offset:" + fieldOffset);
// 使用putInt方法直接修改user对象中偏移量为fieldOffset的字段(age字段)的值为20
unsafe.putInt(user, fieldOffset, 20);
// 使用getInt方法直接读取user对象中偏移量为fieldOffset的字段(age字段)的值,并打印
System.out.println("age:" + unsafe.getInt(user, fieldOffset));
// 调用user对象的getAge方法获取age字段的值,并打印
// 这一步证明了直接内存操作和通过方法访问字段是等效的
System.out.println("age:" + user.getAge());
}
运行代码输出如下:
offset:12
age:20
age:20
可以看到通过 Unsafe 类的objectFieldOffset方法获取到了对象中字段的偏移地址,这个偏移地址不是内存中的绝对地址而是一个相对地址,之后再通过这个偏移地址对int类型字段的属性值进行读写操作,通过结果也可以看到Unsafe 的方法和类中的get方法获取到的值是相同的。
上面的例子中调用了 Unsafe 类的putInt和getInt方法,看一下源码中的方法:
public native int getInt(Object o, long offset);
public native void putInt(Object o, long offset, int x);
先说作用,getInt用于从对象的指定偏移地址处读取一个int,putInt用于在对象指定偏移地址处写入一个int,并且即使类中的这个属性是private类型的,也可以对它进行读写。
但是细心的小伙伴可能发现了,这两个方法相对于我们平常写的普通方法,多了一个native关键字修饰,并且没有具体的方法逻辑,那么它是怎么实现的呢?
2.1.2 native 方法
native方法,简单的说就是由 Java 调用非 Java 代码的接口,被调用的方法是由非 Java 语言实现的,例如它可以由 C 或 C++语言来实现,并编译成 DLL,然后直接供 Java 进行调用。native方法是通过 JNI(Java Native Interface)实现调用的,从 Java 1.1 开始 JNI 标准就是 Java 平台的一部分,它允许 Java 代码和其他语言的代码进行交互。
Unsafe 类中的很多基础方法都属于native方法,那么为什么要使用native方法呢?原因可以概括为以下几点:
- 需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用
- 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用
- 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编
juc包的很多并发工具类在实现并发机制时,都调用了native方法,通过 native 方法可以打破 Java 运行时的界限,能够接触到操作系统底层的某些功能。
对于同一个native方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。
2.2 Unsafe 应用
在对 Unsafe 的基础有了一定了解后,我们来看一下它的基本应用。
2.2.1 内存操作
如果你写过C或者C++,一定对内存操作不会陌生,而 Java 是不允许直接对内存进行操作的,对象内存的分配和回收都是由jvm自己实现。但是在 Unsafe 中,提供的下列接口都可以直接进行内存操作:
//分配新的本地空间
public native long allocateMemory(long bytes);
//重新调整内存空间的大小
public native long reallocateMemory(long address, long bytes);
//将内存设置为指定值
public native void setMemory(Object o, long offset, long bytes, byte value);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes);
//清除内存
public native void freeMemory(long address);
使用下面的代码进行测试:
private void memoryTest() {
// 定义内存块大小为4字节
int size = 4;
// 在堆外内存中分配一个大小为size的内存块,并返回内存地址
long addr = unsafe.allocateMemory(size);
// 重新分配内存,将addr指向的内存块大小扩大到原来的两倍,并返回新的内存地址
long addr3 = unsafe.reallocateMemory(addr, size * 2);
// 打印原始内存地址
System.out.println("addr: "+addr);
// 打印重新分配后的内存地址
System.out.println("addr3: "+addr3);
// 尝试执行以下操作
try {
// 将addr地址开始的size大小的内存块设置为1
unsafe.setMemory(null,addr ,size,(byte)1);
// 循环两次,将addr地址开始的4字节复制到addr3+size*i地址处
for (int i = 0; i < 2; i++) {
unsafe.copyMemory(null,addr,null,addr3+size*i,4);
}
// 打印addr地址处的int值
System.out.println(unsafe.getInt(addr));
// 打印addr3地址处的long值
System.out.println(unsafe.getLong(addr3));
}finally {
// 释放addr地址处的内存
unsafe.freeMemory(addr);
// 释放addr3地址处的内存
unsafe.freeMemory(addr3);
}
}
先看结果输出:
addr: 2433733895744
addr3: 2433733894944
16843009
72340172838076673
分析一下运行结果,首先使用allocateMemory方法申请 4 字节长度的内存空间,在循环中调用setMemory方法向每个字节写入内容为byte类型的 1,当使用 Unsafe 调用getInt方法时,因为一个int型变量占 4 个字节,会一次性读取 4 个字节,组成一个int的值,对应的十进制结果为 16843009,可以通过图示理解这个过程:
代码中调用reallocateMemory方法重新分配了一块 8 字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。
在代码中的第二个 for 循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:
拷贝完成后,使用getLong方法一次性读取 8 个字节,得到long类型的值为 72340172838076673。
需要注意,通过这种方式分配的内存属于堆外内存,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。
通用的操作内存方式是在try中执行对内存的操作,最后在finally块中进行内存的释放。
2.2.2 内存屏障
在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。
而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过组织屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。
在 Java8 中,引入了 3 个内存屏障的方法,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 jvm 来生成内存屏障指令,来实现内存屏障的功能。Unsafe 中提供了下面三个内存屏障相关方法:
//禁止读操作重排序
public native void loadFence();
//禁止写操作重排序
public native void storeFence();
//禁止读、写操作重排序
public native void fullFence();
内存屏障可以看做对内存随机访问操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
看到这估计很多小伙伴们会想到 volatile 关键字了,如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。
基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:
/**
* 实现Runnable接口的更改线程类
*/
@Getter
class ChangeThread implements Runnable {
/**
* 用volatile关键字修饰的标志变量,确保多个线程对它的写操作对其他线程立即可见
*/
volatile boolean flag = false;
/**
* 重写Runnable接口的run方法
*/
@Override
public void run() {
try {
// 线程休眠3秒
Thread.sleep(3000);
} catch (InterruptedException e) {
// 捕获并打印异常信息
e.printStackTrace();
}
// 输出子线程改变flag的值
System.out.println("subThread change flag to:" + flag);
// 将flag设置为true
flag = true;
}
}
在主线程的while循环中,加入内存屏障,测试是否能够感知到flag的修改变化:
public static void main(String[] args){
ChangeThread changeThread = new ChangeThread();
new Thread(changeThread).start();
while (true) {
boolean flag = changeThread.isFlag();
unsafe.loadFence(); //加入读内存屏障
if (flag){
System.out.println("detected flag changed");
break;
}
}
System.out.println("main thread end");
}
运行结果:
subThread change flag to:false
detected flag changed
main thread end
而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。可以用图来表示上面的过程:
了解 Java 内存模型(JMM)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。
上图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。
2.2.3 对象操作
1、对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。
除了前面的putInt、getInt方法外,Unsafe 提供了 8 种基础数据类型以及Object的put和get方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。
阅读 openJDK 源码中的注释可以发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。下面是Object的读写方法:
//在对象的指定偏移地址获取一个对象引用
public native Object getObject(Object o, long offset);
//在对象指定偏移地址写入一个对象引用
public native void putObject(Object o, long offset, Object x);
除了对象属性的普通读写外,Unsafe 还提供了volatile 读写和有序写入方法。volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,以int类型为例:
//在对象的指定偏移地址处读取一个int值,支持volatile load语义
public native int getIntVolatile(Object o, long offset);
//在对象指定偏移地址处写入一个int,支持volatile store语义
public native void putIntVolatile(Object o, long offset, int x);
相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。
有序写入的方法有以下三个:
public native void putOrderedObject(Object o, long offset, Object x);
public native void putOrderedInt(Object o, long offset, int x);
public native void putOrderedLong(Object o, long offset, long x);
有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。
为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:
Load:将主内存中的数据拷贝到处理器的缓存中Store:将处理器缓存的数据刷新到主内存中
顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型,如下图所示:
在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。
而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。
综上所述,在上面的三类写入方法中,在写入效率方面,按照put、putOrder、putVolatile的顺序效率逐渐降低,
2、使用 Unsafe 的allocateInstance方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造方法中对其成员变量进行赋值操作:
@Data
public class A {
private int b;
public A(){
this.b =1;
}
}
分别基于构造方法、反射以及 Unsafe 方法的不同方式创建对象进行比较:
public void objTest() throws Exception {
// 使用构造函数创建A类的实例a1
A a1 = new A();
// 打印a1对象的b属性
System.out.println(a1.getB());
// 使用Class类的newInstance方法创建A类的实例a2
A a2 = A.class.newInstance();
// 打印a2对象的b属性
System.out.println(a2.getB());
// 使用Unsafe类的allocateInstance方法创建A类的实例a3
A a3 = (A) unsafe.allocateInstance(A.class);
// 打印a3对象的b属性
System.out.println(a3.getB());
}
打印结果分别为 1、1、0,说明通过allocateInstance方法创建对象过程中,不会调用类的构造方法。
使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。
在上面的例子中,如果将 A 类的构造方法改为private类型,将无法通过构造方法和反射创建对象,但allocateInstance方法仍然有效。
2.2.4 数组操作
在 Unsafe 中,可以使用arrayBaseOffset方法获取数组中第一个元素的偏移地址,使用arrayIndexScale方法可以获取数组中元素间的偏移地址增量。使用下面的代码进行测试:
private void arrayTest() {
// 创建一个包含三个字符串的数组
String[] array = new String[]{"str1str1str", "str2", "str3"};
// 获取数组的基础偏移量
int baseOffset = unsafe.arrayBaseOffset(String[].class);
System.out.println(baseOffset); // 打印基础偏移量
// 获取数组的索引缩放因子
int scale = unsafe.arrayIndexScale(String[].class);
System.out.println(scale); // 打印索引缩放因子
// 遍历数组
for (int i = 0; i < array.length; i++) {
// 计算当前元素的偏移量
int offset = baseOffset + scale * i;
// 打印偏移量和对应元素
System.out.println(offset + " : " + unsafe.getObject(array, offset));
}
}
上面代码的输出结果为:
16
4
16 : str1str1str
20 : str2
24 : str3
通过配合使用数组偏移首地址和各元素间偏移地址的增量,可以方便的定位到数组中的元素在内存中的位置,进而通过getObject方法直接获取任意位置的数组元素。
需要说明的是,arrayIndexScale获取的并不是数组中元素占用的大小,而是地址的增量,按照 openJDK 中的注释,可以将它翻译为元素寻址的转换因子(scale factor for addressing elements)。
在上面的例子中,第一个字符串长度为 11 字节,但其地址增量仍然为 4 字节。String数组本身不直接存储字符串数据,而是存储指向String对象的引用。每个String对象有自己的内存布局
那么,基于这两个值是如何实现寻址和数组元素的访问呢?
我们把上面例子中的 String 数组对象的内存布局画出来,方便大家理解:
在 String 数组对象中,对象头包含 3 部分,mark word标记字占用 8 字节,klass point类型指针占用 4 字节,数组对象特有的数组长度部分占用 4 字节,总共占用了 16 字节。
第一个 String 的引用类型相对于对象的首地址的偏移量是就 16,之后每个元素在这个基础上加 4,正好对应了我们上面代码中的寻址过程,之后再使用前面说过的getObject方法,通过数组对象可以获得对象在堆中的首地址,再配合对象中变量的偏移量,就能获得每一个变量的引用。
2.2.5 CAS 操作
在juc包的并发工具类中大量地使用了 CAS 操作, synchronized 和 AQS 也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。
在 Unsafe 类中,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作。以compareAndSwapInt方法为例:
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt的例子:
// 定义一个使用volatile关键字修饰的int类型变量a,确保其在线程间的可见性
private volatile int a;
// 主方法,程序的入口
public static void main(String[] args){
// 创建CasTest对象实例
CasTest casTest=new CasTest();
// 创建一个新的线程,并启动它
new Thread(()->{
// 循环从1到4
for (int i = 1; i < 5; i++) {
// 调用casTest对象的increment方法,传入当前循环的值i
casTest.increment(i);
// 打印变量a的值,后面跟一个空格
System.out.print(casTest.a+" ");
}
}).start();
// 创建另一个新的线程,并启动它
new Thread(()->{
// 循环从5到9
for (int i = 5 ; i <10 ; i++) {
// 调用casTest对象的increment方法,传入当前循环的值i
casTest.increment(i);
// 打印变量a的值,后面跟一个空格
System.out.print(casTest.a+" ");
}
}).start();
}
// 私有的increment方法,用于实现CAS操作
private void increment(int x){
// 无限循环,直到成功执行CAS操作
while (true){
try {
// 获取变量a在CasTest类中的偏移量
long fieldOffset =
unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a"));
// 使用unsafe对象的compareAndSwapInt方法执行CAS操作
// 如果a的值等于x-1,则将其更新为x,并退出循环
if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x))
break;
} catch (NoSuchFieldException e) {
// 捕获并打印异常信息
e.printStackTrace();
}
}
}
运行代码会依次输出:
1 2 3 4 5 6 7 8 9
在上面的例子中,使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作。流程如下所示:
需要注意的是,在调用compareAndSwapInt方法后,会直接返回true或false的修改结果,因此需要我们在代码中手动添加自旋的逻辑。
在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。
2.2.6 线程调度
Unsafe 类中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法进行线程调度,在前面介绍 AQS 的我们提到过使用 LockSupport挂起或唤醒指定线程。这个类我们前面也讲到了,这里再回顾一下。
看一下LockSupport的源码,可以看到它也是调用的 Unsafe 类中的方法:
// 定义一个静态方法park,用于阻塞当前线程
public static void park(Object blocker) {
// 获取当前线程的引用
Thread t = Thread.currentThread();
// 设置当前线程的阻塞对象
setBlocker(t, blocker);
// 使用UNSAFE类的park方法阻塞当前线程,传入的参数表示是否是绝对时间,以及阻塞的时间
UNSAFE.park(false, 0L);
// 解除当前线程的阻塞对象
setBlocker(t, null);
}
// 定义一个静态方法unpark,用于唤醒指定线程
public static void unpark(Thread thread) {
// 如果传入的线程不为null
if (thread != null)
// 使用UNSAFE类的unpark方法唤醒指定线程
UNSAFE.unpark(thread);
}
LockSupport 的park方法调用了 Unsafe 的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:
public static void main(String[] args) {
// 获取当前主线程
Thread mainThread = Thread.currentThread();
// 创建并启动一个新线程
new Thread(() -> {
try {
// 新线程休眠5秒
TimeUnit.SECONDS.sleep(5);
// 输出信息,表示新线程尝试唤醒主线程
System.out.println("subThread try to unpark mainThread");
// 使用Unsafe类唤醒主线程
unsafe.unpark(mainThread);
} catch (InterruptedException e) {
// 捕获并打印异常信息
e.printStackTrace();
}
}).start();
// 输出信息,表示主线程即将被阻塞
System.out.println("park main mainThread");
// 使用Unsafe类阻塞主线程
unsafe.park(false, 0L);
// 输出信息,表示主线程被成功唤醒
System.out.println("unpark mainThread success");
}
程序输出为:
park main mainThread
subThread try to unpark mainThread
unpark mainThread success
程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠 5 秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:
2.2.7 Class 操作
Unsafe 对Class的相关操作主要包括类加载和静态变量的操作方法。
01、静态属性读取相关的方法:
//获取静态属性的偏移量
public native long staticFieldOffset(Field f);
//获取静态属性的对象指针
public native Object staticFieldBase(Field f);
//判断类是否需要实例化(用于获取类的静态属性前进行检测)
public native boolean shouldBeInitialized(Class<?> c);
创建一个包含静态属性的类,进行测试:
@Data
public class User {
// 定义一个公共静态变量name,并初始化为"Hydra"
public static String name = "Hydra";
// 定义一个私有变量age,未初始化
int age;
}
// 定义一个私有方法staticTest
private void staticTest() throws Exception {
// 创建User类的一个实例user
User user = new User();
// 打印User类是否需要初始化
System.out.println(unsafe.shouldBeInitialized(User.class));
// 获取User类的名为"name"的字段
Field sexField = User.class.getDeclaredField("name");
// 获取"name"字段的偏移量
long fieldOffset = unsafe.staticFieldOffset(sexField);
// 获取"name"字段的基础对象
Object fieldBase = unsafe.staticFieldBase(sexField);
// 通过基础对象和偏移量获取对象
Object object = unsafe.getObject(fieldBase, fieldOffset);
// 打印获取的对象
System.out.println(object);
}
运行结果:
false
Hydra
在 Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。
在上面的代码中,获取Field对象需要依赖Class,而获取静态变量的属性时则不再依赖于Class。
在上面的代码中,首先创建一个User对象,这是因为如果一个类没有被实例化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:
true
null
02、使用defineClass方法允许程序在运行时动态地创建一个类,方法定义如下:
public native Class<?> defineClass(String name, byte[] b, int off, int len,
ClassLoader loader,ProtectionDomain protectionDomain);
在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:
private static void defineTest() {
// 定义一个文件路径,指向一个名为User.class的类文件
String fileName = "F:\\workspace\\unsafe-test\\target\\classes\\com\\cn\\model\\User.class";
// 创建一个File对象,用于表示这个文件
File file = new File(fileName);
// 使用try-with-resources语句,确保FileInputStream在操作结束后会被关闭
try (FileInputStream fis = new FileInputStream(file)) {
// 创建一个字节数组,用于存储文件内容,数组大小与文件长度相同
byte[] content = new byte[(int) file.length()];
// 读取文件内容到字节数组中
fis.read(content);
// 使用Unsafe类的defineClass方法,根据文件内容定义一个类
// 参数分别为:类的名称(此处为null),字节数组内容,起始位置,长度,类加载器(此处为null),保护域(此处为null)
Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null);
// 使用定义好的类创建一个实例对象
Object o = clazz.newInstance();
// 调用对象的getAge方法,并获取返回值
Object age = clazz.getMethod("getAge").invoke(o, null);
// 打印出获取到的年龄值
System.out.println(age);
} catch (Exception e) {
// 捕获并处理可能出现的异常,打印异常堆栈信息
e.printStackTrace();
}
}
在上面的代码中,首先读取了一个class文件并通过文件流将它转化为字节数组,之后使用defineClass方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。
除了defineClass方法外,Unsafe 还提供了一个defineAnonymousClass方法:
public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);
使用该方法可以动态的创建一个匿名类,Lambda表达式中就是使用 ASM 动态生成字节码的,然后利用该方法定义实现相应的函数式接口的匿名类。
在 JDK 15 发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用 Unsafe 的defineAnonymousClass方法。
2.2.8 系统信息
Unsafe 中提供的addressSize和pageSize方法用于获取系统信息,调用addressSize方法会返回系统指针的大小,如果在 64 位系统下默认会返回 8,而 32 位系统则会返回 4。调用 pageSize 方法会返回内存页的大小,值为 2 的整数幂。使用下面的代码可以直接进行打印:
private void systemTest() {
System.out.println(unsafe.addressSize());
System.out.println(unsafe.pageSize());
}
执行结果:
8
4096
这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测 32 位系统的情况。