#yyds盘点# Java双刃剑之Unsafe类详解,面试可以了解到哪些信息呢

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip1024b (备注Java)
img

正文

} else {

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类:

创建实例

看到上面的这些功能,你是不是已经有些迫不及待想要试一试了。那么如果我们执意想要在自己的代码中调用Unsafe类的方法,应该怎么获取一个它的实例对象呢,答案是利用反射获得Unsafe类中已经实例化完成的单例对象:

public static Unsafe getUnsafe() throws IllegalAccessException {

Field unsafeField = Unsafe.class.getDeclaredField(“theUnsafe”);

//Field unsafeField = Unsafe.class.getDeclaredFields()[0]; //也可以这样,作用相同

unsafeField.setAccessible(true);

Unsafe unsafe =(Unsafe) unsafeField.get(null);

return unsafe;

}

在获取到Unsafe的实例对象后,我们就可以使用它为所欲为了,先来尝试使用它对一个对象的属性进行读写:

public void fieldTest(Unsafe unsafe) throws NoSuchFieldException {

User user=new User();

long fieldOffset = unsafe.objectFieldOffset(User.class.getDeclaredField(“age”));

System.out.println(“offset:”+fieldOffset);

unsafe.putInt(user,fieldOffset,20);

System.out.println(“age:”+unsafe.getInt(user,fieldOffset));

System.out.println(“age:”+user.getAge());

}

运行代码输出如下,可以看到通过Unsafe类的 objectFieldOffset 方法获取了对象中字段的偏移地址,这个偏移地址不是内存中的绝对地址而是一个相对地址,之后再通过这个偏移地址对 int 类型字段的属性值进行了读写操作,通过结果也可以看到Unsafe的方法和类中的 get 方法获取到的值是相同的。

offset:12

age:20

age:20

在上面的例子中调用了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 关键字修饰,并且没有具体的方法逻辑,那么它是怎么实现的呢?

native方法

在java中,这类方法被称为 native 方法( Native Method ),简单的说就是由java调用非java代码的接口,被调用的方法是由非java 语言实现的,例如它可以由C或C++语言来实现,并编译成DLL,然后直接供java进行调用。 native 方法是通过JNI( Java Native Interface )实现调用的,从 java1.1开始 JNI 标准就是java平台的一部分,它允许java代码和其他语言的代码进行交互。

Unsafe类中的很多基础方法都属于 native 方法,那么为什么要使用 native 方法呢?原因可以概括为以下几点:

  • 需要用到 java 中不具备的依赖于操作系统的特性,java在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用

  • 对于其他语言已经完成的一些现成功能,可以使用java直接调用

  • 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如C/C++甚至是汇编

在 juc 包的很多并发工具类在实现并发机制时,都调用了 native 方法,通过它们打破了java运行时的界限,能够接触到操作系统底层的某些功能。对于同一个 native 方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果,至于java如何实现的通过JNI调用其他语言的代码,不是本文的重点,会在后续的文章中具体学习。

Unsafe 应用

在对Unsafe的基础有了一定了解后,我们来看一下它的基本应用。由于篇幅有限,不能对所有方法进行介绍,如果大家有学习的需要,可以下载openJDK的源码进行学习。

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() {

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 块中进行内存的释放。

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 修饰的:

@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 )的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。

3、对象操作

a、对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的 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 的顺序效率逐渐降低,

b、使用Unsafe的 allocateInstance 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:

@Data

public class A {

private int b;

public A(){

this.b =1;

}

}

分别基于构造函数、反射以及Unsafe方法的不同方式创建对象进行比较:

public void objTest() throws Exception{

A a1=new A();

System.out.println(a1.getB());

A a2 = A.class.newInstance();

System.out.println(a2.getB());

A a3= (A) unsafe.allocateInstance(A.class);

System.out.println(a3.getB());

}

打印结果分别为1、1、0,说明通过 allocateInstance 方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了 Class 对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将A类的构造函数改为 private 类型,将无法通过构造函数和反射创建对象,但 allocateInstance 方法仍然有效。

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字节。

那么,基于这两个值是如何实现的寻址和数组元素的访问呢,这里需要借助一点在前面的文章中讲过的Java对象内存布局的知识,先把上面例子中的String数组对象的内存布局画出来,就很方便大家理解了:

在String数组对象中,对象头包含3部分, mark word 标记字占用8字节, klass point 类型指针占用4字节,数组对象特有的数组长度部分占用4字节,总共占用了16字节。第一个String的引用类型相对于对象的首地址的偏移量是就16,之后每个元素在这个基础上加4,正好对应了我们上面代码中的寻址过程,之后再使用前面说过的 getObject 方法,通过数组对象可以获得对象在堆中的首地址,再配合对象中变量的偏移量,就能获得每一个变量的引用。

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 的例子:

private volatile int a;

public static void main(String[] args){

CasTest casTest=new CasTest();

new Thread(()->{

for (int i = 1; i < 5; i++) {

casTest.increment(i);

System.out.print(casTest.a+" ");

}

}).start();

new Thread(()->{

for (int i = 5 ; i <10 ; i++) {

casTest.increment(i);

System.out.print(casTest.a+" ");

}

最后总结我的面试经验

2021年的金三银四一眨眼就到了,对于很多人来说是跳槽的好机会,大厂面试远没有我们想的那么困难,摆好心态,做好准备,你也可以的。

另外,面试中遇到不会的问题不妨尝试讲讲自己的思路,因为有些问题不是考察我们的编程能力,而是逻辑思维表达能力;最后平时要进行自我分析与评价,做好职业规划,不断摸索,提高自己的编程能力和抽象思维能力。

BAT面试经验

实战系列:Spring全家桶+Redis等

其他相关的电子书:源码+调优

面试真题:

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Java)
img

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

}

最后总结我的面试经验

2021年的金三银四一眨眼就到了,对于很多人来说是跳槽的好机会,大厂面试远没有我们想的那么困难,摆好心态,做好准备,你也可以的。

另外,面试中遇到不会的问题不妨尝试讲讲自己的思路,因为有些问题不是考察我们的编程能力,而是逻辑思维表达能力;最后平时要进行自我分析与评价,做好职业规划,不断摸索,提高自己的编程能力和抽象思维能力。

[外链图片转存中…(img-UNiHVaQ0-1713581643619)]

BAT面试经验

实战系列:Spring全家桶+Redis等

[外链图片转存中…(img-LvtrgHvL-1713581643620)]

其他相关的电子书:源码+调优

[外链图片转存中…(img-ub3UuDgC-1713581643620)]

面试真题:

[外链图片转存中…(img-T8l7uaHq-1713581643620)]

[外链图片转存中…(img-OdPYlEpT-1713581643621)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Java)
[外链图片转存中…(img-v16aOSeq-1713581643621)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 28
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值