阅读过 JUC 源码的同学,一定会发现很多并发工具类都调用了一个叫做 Unsafe
的类。
那这个类主要是用来干什么的呢?有什么使用场景呢?这篇文章就带你搞清楚!
Unsafe
是位于 sun.misc
包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe
类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe
类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe
的使用一定要慎重。
另外,Unsafe
提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 native
关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码。
为什么要使用本地方法呢?
- 需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用。
- 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用。
- 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编。
在 JUC 包的很多并发工具类在实现并发机制时,都调用了本地方法,通过它们打破了 Java 运行时的界限,能够接触到操作系统底层的某些功能。对于同一本地方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。
Unsafe 创建
sun.misc.Unsafe
部分源码如下:
public final class Unsafe {
// 单例对象
private static final Unsafe theUnsafe;
......
private Unsafe() {
}
@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
// 仅在引导类加载器`BootstrapClassLoader`加载时才合法
if(!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException("Unsafe");
} else {
return theUnsafe;
}
}
}
Unsafe
类为一单例实现,提供静态方法 getUnsafe
获取 Unsafe
实例。这个看上去貌似可以用来获取 Unsafe
实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException
异常:
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)
为什么 public static
方法无法被直接调用呢?
这是因为在getUnsafe
方法中,会对调用者的classLoader
进行检查,判断当前类是否由Bootstrap classLoader
加载,如果不是的话那么就会抛出一个SecurityException
异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。
为什么要对 Unsafe 类进行这么谨慎的使用限制呢?
Unsafe
提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。
如若想使用 Unsafe
这个类的话,应该如何获取其实例呢?
这里介绍两个可行的方案。
1、利用反射获得 Unsafe 类中已经实例化完成的单例对象 theUnsafe
。
private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}
2、从getUnsafe
方法的使用限制条件出发,通过 Java 命令行命令-Xbootclasspath/a
把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过Unsafe.getUnsafe
方法安全的获取 Unsafe 实例。
java -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径
概括的来说,Unsafe
类实现功能可以被分为下面 8 类:
- 内存操作
- 内存屏障
- 对象操作
- 数据操作
- CAS 操作
- 线程调度
- Class 操作
- 系统信息
如果你是一个写过 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() {
int size = 4;
long addr = unsafe.allocateMemory(size);
long addr3 = unsafe.reallocateMemory(addr, size * 2);
System.out.println("addr: "+addr);
System.out.println("addr3: "+addr3);
try {
unsafe.setMemory(null,addr ,size,(byte)1);
for (int i = 0; i < 2; i++) {
unsafe.copyMemory(null,addr,null,addr3+size*i,4);
}
System.out.println(unsafe.getInt(addr));
System.out.println(unsafe.getLong(addr3));
}finally {
unsafe.freeMemory(addr);
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
块中进行内存的释放。
为什么要使用堆外内存?
- 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
- 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
DirectByteBuffer
是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer
对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。
下面为 DirectByteBuffer
构造函数,创建 DirectByteBuffer
的时候,通过 Unsafe.allocateMemory
分配内存、Unsafe.setMemory
进行内存初始化,而后构建 Cleaner
对象用于跟踪 DirectByteBuffer
对象的垃圾回收,以实现当 DirectByteBuffer
被垃圾回收时,分配的堆外内存一起被释放。
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 分配内存并返回基地址
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 内存初始化
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
// 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier
)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。
Unsafe
中提供了下面三个内存屏障相关方法:
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();
内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence
方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
看到这估计很多小伙伴们会想到volatile
关键字了,如果在字段上添加了volatile
关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag
标志位,注意这里的flag
是没有被volatile
修饰的:
@Getter
class ChangeThread implements Runnable{
/**volatile**/ boolean flag=false;
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("subThread change flag to:" + flag);
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
)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。
在 Java 8 中引入了一种锁的新机制——StampedLock
,它可以看成是读写锁的一个改进版本。StampedLock
提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock
提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。
为了解决这个问题,StampedLock
的 validate
方法会通过 Unsafe
的 loadFence
方法加入一个 load
内存屏障。
public boolean validate(long stamp) {
U.loadFence();
return (stamp & SBITS) == (state & SBITS);
}
没有回复内容