Java从一开始就被定为一个安全的编程语言,它屏蔽了指针和内存的管理,从而减少犯错的风险。但Java仍然为我们留下了一个后门,通过这个后门能够进行一些低级别、不安全的操作,比如内存的申请/释放/访问等操作、底层硬件的原子操作、内存屏障、对象的操作等等。这个后门就是Unsafe类,该类位于sun.misc包下。在新版本的JDK中sum.misc包下的Unsafe类会间接调用jdk.internal.misc包下的Unsafe类,也就是说sun.misc.Unsafe的实现将全部委托给jdk.internal.misc.Unsafe。
如下图中,Java语言层能够通过Unsafe通道来执行底层的操作,这些操作可能涉及到JVM层的堆和方法区,当然也可能涉及到操作系统层,涉及到的更深一层则是硬件层。
Unsafe提供了很多与底层相关的操作,但对于并发和线程来说,我们主要关注与CAS和线程调度相关的方法。其中CAS包括compareAndSwapInt、compareAndSwapLong和compareAndSwapObject三个方法,而线程调度包括park和unpark两个方法。
实际上JDK开发人员做了一些措施来避免Unsafe的滥用,它的设计是为了给JDK内部类库自身使用,所以它在实例化时增加了使用安全校验,必须是受信任代码才能对其进行实例化。该校验主要通过类加载器来判断,后面会讲到详细的判断逻辑。Unsafe提供了getUnsafe方法来获取该对象,所以我们第一种实例化方式就是直接调用该方法,但是这种方式对于我们Java语言层面开发者来说是行不通的,因为没办法通过安全校验,会抛出SecurityException异常。而第二种实例化方式则是通过反射机制来绕过安全检查,我们直接去修改Unsafe类中theUnsafe字段的访问权限使其能被访问,然后获取该字段的值便成功得到Unsafe对象。
所以关于Unsafe实例化工作给一个总结:JDK源码中涉及到Unsafe时实例化都可以直接通过getUnsafe方法,而如果我们在Java语言层面要实例化Unsafe时则需要通过反射的方式。也就是说,本书中所有模拟JDK并发类时都会使用反射方式去获取Unsafe对象。
既然我们已经知道如何去实例化Unsafe对象了,那么接下去就编写一个小例子来理解Unsafe类。这个例子是使用Unsafe对象进行硬件级别的CAS操作,也就是修改UnsafeTest对象的flag字段。其中offset表示flag字段的地址偏移,由Unsafe的objectFieldOffset方法可以获得,这个地址偏移在调用compareAndSwapInt方法时将会作为其中一个参数,除了这个参数外还需要预期值和更新值。最终的输出结果如下:
flag字段的地址偏移为:12 CAS操作后flagֵ的值为:101
我们只关注CAS、线程调度和构造函数相关的几个方法。如下代码所示,其中registerNatives方法用于注册本地方法。然后可以看到构造函数是private的,所以不能通过构造函数来实例化Unsafe对象。而getUnsafe方法则是我们可以用来获取Unsafe对象的方法,不过虽然它是public的,但它对调用者进行安全检查,它会判断调用者是不是由bootstrap类加载器加载,不是的话会抛出SecurityException异常。其它剩下的就是CAS和线程调度相关的方法,它们都是本地方法,后面我们逐个分析。
前面说到compareAndSwapInt是一个本地方法,该方法对应的本地实现处于/openjdk/hotspot/src/share/vm/prims/unsafe.cpp中。对应的代码如下,具体的三步逻辑为:首先获取对象的oop,oop是JVM层一个对象的表示;然后获取该oop对象中对应偏移的地址,也就是Java层对象中某个字段的地址;最后通过Atomic::cmpxchg对指定地址去执行CPU级别的CAS操作。
我们知道不同类型的CPU的指令集是不同的,此外不同的操作系统汇编语言也可能不同,那么就导致不同类型的CPU和不同操作系统都需要编写不一样的汇编语言。这里我们以x86架构的CPU为例,当处于linux系统下汇编则如下面所示,我们主关注其中的 cmpxchgl 指令,它便是CPU级别的CAS操作指令。
类似的,x86架构CPU在windows系统下的汇编如下所示,其中 cmpxchg 指令即是CPU级别的CAS操作指令。
compareAndSwapLong对应的本地实现也是在unsafe.cpp中,由于int是4字节的而long为8字节,所以底层的实现与compareAndSwapInt有些差别。具体代码如下,前面两行仍然通过偏移量来获取对象中某个字段的地址,接下去根据VM_Version::supports_cx8()会分两种情况处理。第一种是CPU指令支持8字节的CAS,那么则直接通过Atomic::cmpxchg来执行CAS操作。第二种是CPU不支持8字节的CAS,此时需要MutexLockerEx锁的协助才能完成CAS,步骤是先加锁,再通过Atomic::load获取对应地址的值,如果值与期望值不相等则直接返回false表示失败,否则继续调用Atomic::store来修改内存值,最终会自动释放锁。
在CPU支持8字节的CAS情况下,不同CPU类型和不同操作系统同样需要编写不同的汇编语言。以x86为例,linux下的汇编如下,此时会用到cmpxchgq指令来实现硬件级别的CAS操作。
而对于windows系统,汇编代码则如下,主要使用了cmpxchg8b指令来实现CAS操作。
compareAndSwapObject对应的本地实现如下,先分别获取三个对象的oop,对应为更新后的对象x、期望的对象e和待更新的对象p。然后获取待更新对象p的地址,接着调用oopDesc::atomic_compare_exchange_oop来对JVM层面的对象进行CAS操作,如果返回值不等于期望对象e则表示更新失败,直接返回false。最后是设置内存屏障,保证执行的顺序性和对其它线程的可见性。
再详细看 oopDesc::atomic_compare_exchange_oop
核心方法,根据UseCompressedOops分两种情况处理,该函数表示JVM中对象的指针是否使用了压缩指针,压缩指针的目的是为了节约内存。对于压缩指针的情况,将值都转换成narrowOop类型,该类型其实是无符号整型,然后再调用了Atomic::cmpxchg进行CPU级别的CAS操作,最后再转成未压缩指针。而对于非压缩指针的情况,则调用 Atomic::cmpxchg_ptr
进行CPU级别的CAS操作,此时的指针大小为64位,可转成long型再执行CPU级别的64位的CAS操作。
park方法的本地实现如下,其中我们只关注方框内的一行,这是实现park的核心方法,其它代码我们直接忽略掉。每个thread都有一个parker对应,由它来实现park操作。此外,由于不同的操作系统实现不一样,所以需要分多个系统各自实现,下面分别看linux和windows的实现。
先看linux的实现,核心实际上就是使用了pthread库,通过它提供的互斥锁和条件等待等函数来实现park功能。 _counter
变量用于表示信号量,当可通过时为1不可通过时为0。如果为1就可以直接返回,因为已经有许可了,也就是先调用过unpark了。接着对时间time进行转换,然后尝试获取互斥锁,只有成功获取互斥锁才能往下执行。当time为0时调用不带超时的 pthread_cond_wait
进入阻塞而等待唤醒信号,否则调用带超时的 pthread_cond_timedwait
进入阻塞而等待唤醒信号。最终释放互斥锁。
对于windows的实现,则是直接通过windows提供的WaitForSingleObject函数进行park操作。
unpark实现如下,前面的逻辑都是为了获取线程对应的Parker指针,并非重点,其中我们只关注方框的那行代码。同样的,我们来看linux和windows对应的实现。
linux的unpark实现逻辑是:先通过pthread_mutex_lock获取互斥锁,然后将 _counter
的值修改为1,即表示许可为1。最后通过 pthread_cond_signal
去唤醒前面park中进入阻塞的线程。
windows的unpark则是简单调用SetEvent函数来设置许可信号,park操作中的WaitForSingleObject函数得到信号后则能往下执行。
有时候在新版本的JDK中会看到用VarHandle替代了Unsafe的一部分功能,实际上它们实现的本质都类似,但按官方的说法是VarHandle更安全更易用更高性能,并且官方推荐不要使用Unsafe。不管如何,只要我们掌握了实现的原理,那么接口如何变化都能轻松应对。
本文主要讲解了Unsafe这个低级别且不安全的类,通过该类能进行一些底层的操作。对于我们并发方面来说,它主要提供了五个相关的方法,其中三个用于CAS操作而另外两个用于线程调度操作。我们还介绍了在Java语言层面如何来实例化Unsafe对象,并提供了一个例子帮助大家理解。最后分别深入分析了Unsafe类提供的五个方法,从Java层到JVM层的实现代码,还包括不同的CPU架构和不同的操作系统的实现。
作者简介:笔名seaboat,擅长人工智能、计算机科学、数学原理、基础算法。出版书籍:《Tomcat内核设计剖析》、《图解数据结构与算法》、《人工智能原理科普》。
推荐作者的 一个 Java并发原理专栏,有需要的朋友可看看,已经有180+人参与学习。