深入分析虚拟机创建对象的两种方式以及如何在并发情况下实现线程安全
创建对象的两种方式
如果了解过虚拟机,我们都知道虚拟机在创建对象的时候采用两种方法:
- 指针碰撞
- 空闲列表
那么这两种方式有什么不同,又分别是怎么实现的呢?
指针碰撞
首先我们说下指针碰撞,我们可以把Java堆想象成一个大小是100的线性内存(纯粹为了容易看懂才去设定的一个值),起始位置是0,结束位置是100,也就是0-100,在起始位置0的地方有一个指针,这个时候有一个新生对象A产生,这个A所需要的内存大小是5,这个时候指针就会从0移动到5,提供大小为5的内存空间让A来使用,那么此时Java堆中可以使用的内存大小就是5-100,如果此时又有一个新生对象B产生,占用内存大小是7,那么指针就会从5移动到12,再腾出7的内存空间给B使用,那么剩下的堆内存就是12-100,以此类推,这种利用指针来分配内存的方式就叫指针碰撞,这个指针的两侧分别代表了已分配的内存空间和可用的内存空间。
空闲列表
如果是空闲列表的方式呢?
此时我们还是把堆内存想象成一个大小为0-100的线性内存,但是这一次没有指针了,这个时候有一个新生对象A产生,所需要的内存空间是5,此时虚拟机会随机的从0-100的内存空间中分配出大小为5的内存给A来使用,有可能是0-5,有可能是7-12,也有可能是95-100,这个我们无法控制,假设说分配的内存空间是7-12,那么此时的堆内存就被划分成了两部分,0-7和12-100,如果此时又有一个新生对象B产生,占用内存大小是9,那么虚拟机就会从所有空闲的内存中去随机分配,0-7大小肯定不够,那么就只能从12-100中去分配,同样的,也许分配了12-21,也许分配了70-79,这个我们也没办法确定,分配完成之后,堆内存又被划分为了几个空闲的部分,再次有新生对象产生的时候,虚拟机再从空闲内存中找一块儿大小合适的内存去随机分配,以此类推,这个就叫做空闲列表。
具体虚拟机采用哪种方式,这个根据Java堆是否规整来觉得,而Java堆是否规整,又取决于采用的垃圾收集器是否具有压缩整理功能决定。
线程安全
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试
- TLAB
这两种方式又是如何实现以及有什么不同呢?
CAS+失败重试
首先我们说下CAS+失败重试的方式,我们知道CAS是乐观锁的一种实现方式,通过比对原值和旧的预期值来确定是否要将原值更改为新值,如果通过CAS来实现线程安全,那么需要三个因子,首先是在主内存中的一个原值,然后第二个是这个原值在各个线程中的副本,再接下来就是新值。
如果采用的是指针碰撞的方式进行对象的内存分配,那么这个原值就是当前指针的位置,假设说现在是初始化状态,那么指针的位置就是0,也就是原值就是0,这个0会在主内存中存放着,假设现在有两个线程A和B,每个线程都在执行着创建对象的操作,这两个线程中保存着原值的副本,此时的原值是0,副本也是0,那么新值呢?新值就是需要的内存量,假设说对象A需要5的内存,对象B需要7的内存。
现在两个线程开始创建对象,我们采用CAS+失败重试的方法来确保线程安全。
首先A线程开始创建对象,此时触发乐观锁的机制,A读取到了目前的内存情况,也就是指针的初始位置0,因为是乐观锁,所以在没有提交的时候并不会触发冲突检查,这个时候时间切换到线程B,线程B也开始创建对象,同样读取到了目前的内存情况,同样的指针位置是0,因为没有提交,同样不会触发冲突检查,但是线程的工作是CPU轮流安排时间片进行的,同一时间只会有一条线程执行任务,这个时候又切换到线程A,A开始提交,提交的时候触发了冲突检查,对原值以及旧的预期值进行比对,原值是0,预期原值也就是线程里面的原值副本,此时线程A的旧的预期值也是0,比对通过,将新值赋给原值,新值是5,那么指针就会移动5个单位,此时指针的位置就是在5,同时将线程A的预期值更改为5,现在时间再次切换到线程B,线程B开始提交,触发乐观锁机制,进行冲突检查,此时的原值是5,线程B的旧的预期值是0,比对不通过,此时虚拟机什么都不做,只是把线程B的旧的预期值更新为5,然后触发失败重试,线程B会再次尝试提交,再次出发冲突检查,那么这个时候检查就通过了,通过之后将新值赋给原值,需要注意的地方是,这个赋值并不是把5改变成7,而是将指针移动7个单位,移动到12,然后再把线程B的旧的预期值更新为12,以此类推。
那么如果是空闲列表呢?又是如何通过CAS+失败重试的方式来保证线程安全呢。
其实道理是一样的,同样的AB两个线程都需要创建对象分配空间,A需要5,B需要7。
首先是A线程,此时的原值是可用的内存空间0-100,A和B的旧的预期值也都是0-100,这个时候A先去创建对象,检查通过后,虚拟机分配了7-12的内存给了A线程的对象,同时将原值以及A线程的旧的预期值更改为0-7,12-100,这个时候B再去提交创建对象的时候,旧的预期值是0-100,与0-7,12-100比对不通过,将预期值更新为0-7,7-12,然后触发失败重试,再次提交的时候比对通过,提交成功,更新原值和线程B的预期值,以此类推。
也许我讲的不是很详细也不是很专业,但是大体上是这样实现的。
TLAB
那么如果是TLAB的方式呢?
TLAB,全文Thread Local Allocation Buffer,本地线程分配缓冲,每个线程都会在Eden空间申请到一个TLAB,大小占Eden空间的1%,当然申请这个空间的过程是线程同步的,这个同步的实现也是依赖于CAS+失败重试的方式,具体我们就不再详细介绍,只是原值和新值不同而已,可以把TLAB想象成一个对象,占用内存大小就是Eden空间的1%即可。
当这个线程需要创建对象的时候,直接在TLAB里面创建就行了,这样就避免因并发而导致的线程安全问题。
当然,有这样的一种情况,现在这个线程需要创建一个对象,但是当前的TLAB的空间不足了,怎么办,它会再向Eden空间去申请一个TLAB,申请的过程是线程同步的,它会把这个对象放到新的TLAB中,也就是说一个线程并不是只有一个TLAB。
那如果这个对象特别大,哪怕是一个新的TLAB也放不下呢?直到这个时候,线程才会去把对象直接创建在Eden空间,再次采用CAS+失败重试的方法去保证线程同步。
也就是说,采用这种方式,线程会向Eden空间申请线程私有的TLAB来创建对象,确保线程安全,除非说现在的TLAB不够用了,再去申请新的TLAB的时候才会同步锁定,或者说是对象特别大,一个全新的TLAB空间都装不下了,必须去Eden空间创建,才会同步锁定。
但一般来说,创建的对象都是特别小的,也都是会迅速销毁的,所以这种方式从效率上来讲还是比较高的。