前言
本文是本人学习《深入理解Java虚拟机:JVM高级特性与最佳实践》一书时,对自动内存管理机制部分章节内容的总结。
JVM运行时内存区域
Java虚拟机(JVM,Java Virtual Machine )在执行Java程序时的内存分区,主要包括程序计数器(Program Counter Register)、虚拟机栈(Virtual Machine Stack)、本地方法栈(Native Method Stack)、堆(Heap)和方法区(Method Area)。
程序计数器(Program Counter Register)
程序计数器可以看作是Java字节码文件的行号指示器,字节码解释器就是通过改变程序计数器的数值来解释Java字节码文件的(熟悉计算机组成原理的同学可能发现,JVM中的程序计数器在一定程度上模仿了CPU中的程序计数器组件工作原理,只不过它所含值指向的是Java字节码指令),理论上是这样的(实际上各种虚拟机可能会用更高效的方式去实现)。
JVM的多线程是通过轮流切换和分配处理器执行时间来进行的,如果程序计数器是共享的,那么将难以保证每个线程在切换后再次执行时,还能准确从被切换前的位置继续执行,因此,JVM为每个线程都配备了独立的程序计数器,这种方式被称作线程私有。也就是说,程序计数器的生命周期跟其相应的线程的生命周期一样。
由于程序计数器的值是指向Java字节码指令的,因此如果JVM执行的是Native方法,那么它的值则为空(Undefined)。
程序计数器是JVM运行时数据区中唯一一个不会发生内存溢出错误(OutOfMemoryError)的内存区域。
虚拟机栈(Virtual Machine Stack)
虚拟机栈也是线程私有的,它描述的是JVM运行时的一个内存模型:每个方法被执行时都会同时创建一个栈帧,用以存储局部变量表、操作栈、动态链接和方法出口等数据。每一个方法从被调用直到被执行完成的过程,都是对应一个栈帧从虚拟机栈中从入栈到出栈的过程。
如今大部分Java程序员所关注的“栈内存”,通常是指虚拟机栈中的局部变量表,只因它与内存管理相关更为密切。
局部变量表用以存储基础数据类型(boolean/char/byte/short/int/long/float/double)、对象引用类型(refrence类型,根据不同的虚拟机实现,它可以是一个对象起始地址的指针,也可以是一个句柄或其他与该对象地址相关的信息)以及returnAddress类型(指向一条字节码指令的地址)。
局部变量表的大小在编译期就已经完成分配,运行期间是不会改变的。每个局部变量表里都有多个插槽(Slot),每个Slot的长度都是32位,因此除了long和double这两种单个长度64位的类型会占2个Slot之外,其余类型都是占1个Slot。
JVM规范中规定了两种情况:
1. 如果线程请求的栈深度超出了虚拟机栈所允许的深度,则会抛出StackOverflowError。
2. 如果虚拟机栈可以动态扩展,但扩展时无法申请到足够的内存,则会抛出OutOfMemoryError。
本地方法栈(Native Method Stack)
本地方法栈跟虚拟机栈作用上的性质差不多,不同点是,虚拟机栈为Java方法服务,而它则为虚拟机使用到的Native方法服务。
虚拟机规范中,对本地方法栈的使用语言、使用方式和数据结构都没有强制规定,不同的虚拟机可以自由实现它,甚至可以将它与虚拟机栈结合在一起(例如Sun HotSpot虚拟机就这么实现)。
同样,它也会抛出StackOverflowError或OutOfMemoryError。
堆(Heap)
针对大部分应用来说,堆是JVM内存中最大的一块,其唯一目的就是存放对象的实例,几乎所有对象的实例都存放在这块区域内。堆是线程共享的,它在虚拟机启动时就已经创建。同时,它也是垃圾回收器的主要管理区域,因此也被称为GC堆。
从内存回收的角度来看,由于如今的收集器都是采用分代收集算法,因此它还会被分为新生代和老年代,再细致一点的还有Eden空间、From Survivor空间、To Survivor空间等。
从内存分配的角度来看,由多线程共享的Java堆中还可能会划分出多个线程私有的分配缓冲区(TLAB,Thread Local Allocation Buffer)。
进一步划分的目的主要是方便内存回收或分配,无论如何划分,其区域所保存的数据都是对象的实例。
Java虚拟机规范中,堆存放在内存空间中的物理地址可以不连续,只要逻辑地址连续即可。在实现时,可实现固定大小,也可实现扩展。
当堆中没有足够内存能完成实例分配,且无法再扩展时,会抛出OutOfMemoryError。
方法区(Method Area)
方法区也是线程共享的,它用以存储已经被虚拟机加载的类信息、常量、静态变量以及被即时编译器编译的代码。
Java虚拟机规范中,方法区被描述为堆的一个逻辑部分,但它有一个别名叫Non-Heap(直译过来就是“非堆”的意思),目的应该是为了跟堆区分开来。
不少人习惯将方法区称之为“永久代”,只因Sun HotSpot虚拟机将GC分代收集管理机制扩展到了方法区中,而实际上,不同的Java虚拟机采用的GC分代收集管理机制在实现上并非完全一致,因此“永久代”通常只是针对Sun HotSpot这种Java虚拟机而言的,也就是说这种定义并不严格。
不过,在虚拟机规范中,针对方法区的限制也确实非常宽松,除了它可以像堆一样存放在不连续的物理空间以及可实现固定大小或扩展之外(毕竟是堆的一个逻辑部分),还可以选择不实现内存回收(如果选择不实现,那就是名副其实的“永久代”了)。
对于方法区来说,这块区域的内存回收主要是针对常量池的回收以及对类型的卸载,通常内存回收行为在这块区域很少出现,毕竟针对常量池的回收的频率是很低的,而对类型的卸载所需条件更尤为苛刻,当然,这只是说明这块区域的内存回收发生的频率极低,并不代表里面的数据真的可以永久存在。(在Sun公司的BUG列表中,曾出现过若干过比较严重的BUG都是因为对方法区的内存回收不完全而导致内存溢出,因此,针对方法区的内存回收是有必要的。)
当方法区无法满足内存分配需求时,会抛出OutOfMemoryError。
运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一个部分。
Class文件中,除了有类的版本、字段、方法、接口的描述等信息外,还有一项信息是常量池(Contact Pool Table),里面存储了编译期所产生的各种字面量和符号的引用,而这部分的内容会在类加载后,存放在方法区内的运行时常量池中。
Java虚拟机规范对Class文件中的每一个部分(当然也包括其常量池)的格式都有严格的规定,每一个字节用以存储哪种数据都必须符合规范上的要求,这样才能被虚拟机所认可、装载和执行。但对于运行时常量池来说,Java虚拟机规范并没有作任何细节上的要求,不同虚拟机可以自由实现该区域,不过通常情况,除了保持Class文件中所描述的符号引用之外,运行时常量池还会存储翻译出来的直接引用。
Class文件中的常量池和运行时常量池在除了上述区别之外,还有一个区别就是,运行时常量池具备动态性,因为Java语言并不要求常量一定只能在编译期产生,也就是说并非只能通过预装进Class文件的常量才能进入方法区中的运行时常量池中,在运行期间也可以将新的常量放入池中(据说这种特性被开发人员利用得比较多的是String类的intern()方法)。
当常量池无法申请到足够内存时,会抛出OutOfMemoryError。
直接内存
这里的直接内存指的是JVM运行时所直接涉及到的计算机物理内存,并非JVM运行时数据区的一部分。
尽管直接内存不受Java堆大小的限制,但必定会受计算机物理内存以及处理器寻址空间大小的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但如果忽略了直接内存,使得各个区域的内存大小总和超过了计算机物理内存的限制,则容易会在动态扩展时出现OutOfMemoryError。
内存分配策略
对象的内存分配,主要就是往堆上分配(也存在被JIT编译后,对象被拆散为标量并间接往栈上分配的情况),且优先分配在新生代的Eden空间中,如果启动了本地线程分配缓冲,那么将会优先分配在线程的TLAB上,少数情况下会直接分配到老年代中。分配的规则并不是固定的,主要取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。
大多数情况下,对象优先分配在Eden空间中,当Eden空间没有足够的空间进行分配时,会发出一次Minor GC,针对新生代进行内存回收。
虚拟机也为每个对象定义了一个对象年龄计数器,如果对象在经历了Minor GC还能存活,且Survivor空间足以存放它的话,那么这个计数器的值将会置为1,之后,只要这个对象每经历一次Minor GC还存在于Survivor空间,那么年龄计数器都会进行自增操作,当这个对象的年龄到了一定的数值时(默认为15,可通过参数-XX:MaxTenuringThreshold来设置),就会被移到老年代中去。
为了更好适应不同程序的内存状况,虚拟机并非总是要求对象达到参数-XX:MaxTenuringThreshold所设置的年龄值才能进入老年代,当Survivor空间同年龄对象空间总和占该空间一半以上时,当前空间中年龄大于或等于该年龄的对象都能直接进入老年代,无需达到MaxTenuringThreshold。
还有一种情况,那就是如果对象是大对象(需要大量连续内存空间的对象,典型的例子就是很长的字符串或数组),那么它将直接进入老年代,不需要先分配在Eden空间。(因此,开发人员需要注意,尽量避免创建那种“短命”的大对象。)
内存回收策略
堆中几乎存放了所有Java对象的实例,而垃圾收集器在对堆中内存进行回收时,第一步就是要确定堆中哪些对象还“活着”,哪些已经“死去”。
判断对象是否存活的算法通常有两种,分别是引用计数算法和根搜索算法。
引用计数算法
引用计数算法的实现是,给对象添加一个引用计数器,每当这个对象被一个地方引用,引用计数器就作加1操作,而当引用失效时,则作减1操作,任何时刻引用计数器数值为0的对象就是不可能被使用的对象。
这种算法的实现相对来说比较简单,但存在一个缺陷,就算无法解决对象之间相互循环引用的问题。
假设有一个对象a以及另一个对象b,两个对象都只拥有一个成员变量,且数据类型相同,当a的成员变量引用了b的成员变量,而b的成员变量也引用a的成员变量,除此之外,这两个对象再无任何引用,那么这两个对象各自对应的引用计数器都会进行加1操作,假设这两个对象已经不可能再被访问,由于各自的引用计数器的值都不为0,因此不会通知垃圾收集器对它们进行收集,从而使得在进行内存回收后,内存中依然留有这种已经“死去”的对象。
根搜索算法
根搜索算法的基本思路是,通过一系列名为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径称为引用链(Reference Chain),当一个对象到GC Root没有与任何引用链相连时,则证明此对象是不可用的。
在Java语言里,可作为GC Roots的对象包括以下几种:
1. 虚拟机栈(栈帧中的局部变量表)中的引用的对象。
2. 方法区中的类静态属性引用的对象。
3. 方法区中常量引用的对象。
4. 本地方法栈中JNI(即常说的Native方法)的引用的对象。
如今大多数主流的商用语言(如Java和C#,甚至更古老的Lisp)都是使用这种算法来实现垃圾回收的。
在根搜索算法中,不可达GC Roots的对象并非一定会被回收,垃圾收集器在对这些对象进行收集工作,至少要经历两次标记的过程:
如果对象再进行根搜索算法后,被发现没有与GC Roots的任何引用链相连,那么该对象将会被进行第一次标记,并且会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize()方法,或者覆盖了但finalize()方法已经被虚拟机调用过,那么虚拟机将会视该对象为“没必要执行finalize()方法”。
若虚拟机将会视该对象为有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动创建的Finalizer低优先级线程去执行,GC也会对队列中的对象进行第二次小规模的标记。
而Finalizer线程被激活后,虚拟机并不会等待该线程运行结束,这是为了防止某个对象在该线程执行finalize()方法时过于缓慢或发生了死锁,导致其他对象一直处于等待的状态甚至导致整个内存回收系统发生崩溃。
因此,finalize()方法可以说是对象逃脱死亡命运的最后一次机会。在Finalizer线程执行中,若GC标记到该对象前,虚拟机已经执行结束了,或该对象重新与GC Roots的引用链上任意对象发生关联,那么该对象就顺利存活。
垃圾收集算法思想
标记-清除算法
“标记-清除”算法是最基础的垃圾收集算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,然后统一回收所有被标记的对象。
“标记-清除”算法有两个主要的缺点:
1. 效率问题,无论是标记的过程还是清除的过程,它们的效率都不高。
2. 空间问题,在进行一次标记清除后,空间中普遍存在大量不连续的空间碎片,当程序下一次执行需要一个较大的连续的内存空间时,若当前空间无法满足要求,那么还要提前再进行一次标记清除。
于是,基于这种最基础的算法,衍生出了改进其缺点的其他算法。
复制算法
复制算法是为解决效率问题而来的,它的主要思路是将可用内存空间容量划分为大小相同的两部分,每次只使用其中一部分,当这部分的空间用完了,就将存活的对象移到另一部分去,并将原来部分的空间清空。
这样一来,每次进行内存回收时,垃圾收集器只需要对内存空间其中一半空间进行回收,也不用考虑碎片问题,每次回收之后,只需移动堆顶指针,按顺序分配内存即可。不过这种算法需要付出将实际使用内存缩小为原来的一半的代价。
如今的商业虚拟机普遍采取这种算法来回收新生代,IBM研究表明,新生代中的对象高达98%是“朝生夕死”的,也就是说,回收新生代时所采取的复制算法,并不需要按照1:1的比例划分可用内存空间,而是将内存划分为一块较大的Eden空间和两小块Survivor空间,每次只使用Eden空间和其中一块Survivor空间,当Eden空间和被使用的那块Survivor空间用完后,就将存活的对象移到另一块Survivor空间去,然后将Eden空间和原来被使用的那块Survivor空间清空。
Sun HotSpot虚拟机中,Eden空间和Survivor空间的大小比例是8:1,也就是说,保留空间只占整体空间的10%,而一般场景下的内存回收后,新生代中存活的对象只有2%,在这种空间划分下,基本能顺利进行内存回收。
当然,并不能保证每次内存回收后,新生代中存活的对象都不会超过10%,这种情况下就需要借助其他内存空间进行分配了(Sun HotSpot虚拟机是借助老年代空间来分配)。
标记-整理算法
尽管复制算法是为解决效率问题而生的,但当存活的对象较多时,反而会因进行过多复制操作而导致效率变低,况且如果不想要浪费掉50%的空间,还需要借助其他内存空间来解决分配问题,以及应对对象100%存活这种极端情况。
显然,复制算法在老年代中并不适用,因此,有人根据老年代的特点,提出了这种标记-整理算法。
这种算法的标记过程跟标记-清除算法一致,但回收过程并非直接清理掉可回收对象,而是将所有存活对象往一端移动,然后将端边界之外的所有空间全清空掉。
分代收集算法
如今的商业虚拟机基本都采用分代收集算法,这种算法其实就是将内存空间根据对象的存活周期划分为不同的几个部分,然后针对各部分的特点采取最适合的算法。例如新生代采取复制算法,老年代采取标记-清除算法或标记-整理算法。
新生代GC(Minor GC)指发生在新生代的垃圾回收动作,因新生代对象“朝生夕灭”的特点,Minor GC的出现十分频繁。
老年代GC(Major GC/Full GC)指发生在老年代的垃圾回收动作,Major GC的出现通常会伴随至少一次Minor GC(但并不绝对)。Major GC的速度一般比Minor GC的速度慢10倍以上。
无论是新生代GC还是老年代GC,它们执行垃圾收集时,都会暂停其它所有线程的工作(主要发生在对对象进行初始标记和重复标记的时候),这种机制被称为“Stop The World(STW)”,这种暂停所造成对整体程序的停顿一直为人所诟病,Sun HotSpot虚拟机研发团队也一直在研究关于这方面的优化,迄今为止出现了不少越来越优化的垃圾收集器,但依旧不能完全消除这种暂停,只能尽可能使暂停时间越来越短(如以缩短这种暂停时间为目标的CMS收集器)。
而事实上,对于大部分应用程序,STW导致的延迟都是可以忽略不计的,这是因为通常情况下,Eden区中98%的对象都能被认为是垃圾(即被认为不需要执行finalize()方法,F-Queue队列中的对象数量极少,大大缩短了回收过程),永远也不会被复制到 Survivor 区或者老年代空间。
(也有资料指出,Full GC并非Major GC,Full GC会清空包括新生代、老年代,也有资料指出有些垃圾收集器还会回收一部分永久代在内的堆内存。个人认为,如果只是清理了新生代和老年代,还不足以说明Full GC和Major GC并不一致,毕竟上文也说了,Major GC的出现通常会伴随至少一次Minor GC,也就是说,通常情况下,老年代的回收动作都会伴随新生代回收内存动作。由于官方并没有针对Full GC的有关说明,因此这里只作保留参考。)
垃圾收集器
垃圾收集算法是有关如何回收内存中的无用对象的一种思想,而如何实现这种思想,就是垃圾收集器的任务了。
JVM虚拟机规范中没有规定垃圾收集器该如何实现,因此不同的虚拟机可以有不同的实现方式。
Sun HotSpot虚拟机中的垃圾收集器
Sun HotSpot虚拟机中主要包含了7种垃圾收集器,分别是Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS和G1。
上图是HotSpot虚拟机所包含的收集器,图中若两个收集器之间存在连线,则表示这两个收集器可以组合使用。
Serial收集器
Serial收集器是最基础的垃圾收集器,也是对HotSpot虚拟机来说历史最悠久的收集器,它属于作用于新生代的收集器,采取复制算法。
它是单线程收集器,对于限定单个CPU来说,没有作线程交互的它也不会多出额外的开销,同时它运行时,后台会暂停用户其他所有线程的运行(即STW),专心进行单线程内存回收。
这一特点既是它的优点,也是它的缺点。
优点是在限定单个CPU的环境下,它简单又高效,且对于大部分用户桌面应用场景(Client端)来说,分配给虚拟机的内存管理通常不会很大,STW所花时间往往能控制住几十毫秒最多一百毫秒以内,只要不频繁发生就没多大的问题;
缺点也正因为它不支持并发收集,且它的STW机制是耗时较长的。
ParNew收集器
ParNew收集器是多线程垃圾收集器,也是作用于新生代的垃圾收集器,但它其实就是Serial收集器的多线程版本。
它和Serial收集器共用了很多代码,线程量的支持方面是它与Serial收集器之间的唯一区别,其余无论是可控参数、收集算法、STW机制、对象分配规则、回收策略等,两者都完全一样。
单CPU的环境下,它因需要进行线程交互,反而比不上Serial收集器的效率。但在多CPU或多核环境中,它相比起Serial收集器来则大大提升了系统资源的利用率。
尽管ParNew收集器相较于其他收集器并没有什么创新,但却是Server模式下使用最多的收集器,还有一个比较主要的原因是,它能够与CMS收集器进行组合使用(有关CMS收集器下文会提及)。
Parallel Scavenge收集器
Parallel Scavenge收集器是多线程收集器,作用于新生代,使用的也是复制算法。
它与其他收集器的关注点不同,大多数收集器关注如何缩短STW的时间,而它的主要目的是为了达到一个可控制的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),因此也被称作“吞吐量优先”收集器。
高吞吐量能最高效的利用CPU时间,尽快地完成程序运算任务,主要适用于后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用来精确控制吞吐量,一个是最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数,以及一个直接设置吞吐量大小的-XX:GCTimeRatio参数。
MaxGCPauseMillis参数允许用户设置一个值大于0的毫秒数,垃圾收集器将尽可能保证STW时间不超过设定的毫秒,但需要记住的是,停顿时间的缩短是需要牺牲吞吐量和新生代空间来获取的。
GCTimeRatio参数的值应当是一个大于0且少于100的整数,例如设置为1(最低值),那么吞吐量就是50%(1/(1+1));如果设置为99(最高值),那么吞吐量就是1%(1/(1+99))。
Parallel Scavenge收集器还有一个值得注意的开关参数,就是-XX:+UseAdaptiveSizePolicy参数。当这个参数打开后,就不需要手动指定新生代的大小(-Xmn)、Eden区与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据系统当前运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间和吞吐量。而这种机制被称作GC自适应的调节策略。
Parallel Scavenge收集器与ParNew收集器在区别上,除了关注点不同以及ParNew收集器并没有GC自适应调节策略之外,还有一点就是Parallel Scavenge收集器并不能跟CMS收集器组合使用,只因Parallel Scavenge收集器(以及下文提到G1收集器)并没有使用传统的GC收集器框架,而是独立实现,其余集中收集器则共用了部分框架代码。
Serial Old收集器
Serial Old收集器是老年代垃圾收集器,是Serial收集器的老年代版本,它是单线程的,使用的是“标记-整理”算法。
它跟Serial收集器都是主要在Client模式下使用。如果在Server模式下,它还有两个用途,一个是在JDK1.5之前与Parallel Scavenge收集器进行组合使用,另一个则是充当CMS收集器的后备预案,当CMS收集器发生Concurrent Mode Failure时则会派上用场。
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,属于多线程收集器。
在它出现之前,Parallel Scavenge收集器只能与Serial Old组合使用(毕竟Parallel Scavenge收集器不能与CMS配合使用),而由于Serial Old这种老年代单线程收集器在Server端服务器性能上的“拖累”,使得它未必能使吞吐量达到最大化的效果,又因为老年代收集中无法充分利用多CPU或多核的能力,在老年代空间很大且硬件较为高级的环境中,这种组合下的吞吐量还比不上ParNew和Serial Old的组合,因此导致它处于一个尴尬的地位。
而在JDK1.6开始,Parallel Old收集器的出现主要是为了解决这一问题,它与Parallel Scavenge收集器配合使用,才能更好的体现“吞吐量优先”。
CMS(Concurrent Mark Sweep)收集器
CMS收集器是一种以获取最短停顿回收时间为目标的垃圾收集器,是HotSpot虚拟机的第一款真正意义上的并发收集器,它基本上能实现垃圾收集线程与用户线程同时工作,可以说是一款划时代的收集器。
CMS收集器是基于“标记-清除”算法的收集器,它的运作过程主要分为四个步骤:
1. 初始标记,需要STW,但仅仅标记一下GC Roots能直接关联到的对象,速度很快。
2. 并发标记,就是进行GC Roots Tracing的过程。
3. 重复标记,需要STW,它是为了修正在并发标记期间,因用户进程继续运行而导致标记发生变动的那一部分对象的标记记录。
4. 并发清除,并发清除可回收对象。
Sun公司的一些官方文档中也称CMS收集器为并发低停顿收集器,从名字上可以说是很好的反映了这款收集器拥有并发收集以及低停顿的优点,然而它还是存在以下缺点:
1. 对CPU资源非常敏感,常需要通过牺牲吞吐量来达到低停顿的效果。
2. 无法处理浮动垃圾,可能会出现Concurrent Mode Failure。
3. 容易造成大量空间碎片的出现(因为使用的是基于“标记-清理”的算法)。
G1(Garbage First)收集器
G1收集器也是一款并发收集器,它是基于“标记-清理”算法的收集器。
它的优点主要有两个,一个是它不会产生空间碎片,另一个是它可以非常准确的控制停顿时间,可以在基本不牺牲吞吐量的前提下实现低停顿。
G1收集器并不像其他收集器那般主要针对新生代或老年代进行收集,而是将整个Java堆划分为大小固定的区域(Region),并跟踪这些区域的垃圾堆积情况,在后台维护一个优先列表,每次根据允许收集的时间,优先回收垃圾堆积最多的区域的内存,从而保证了它在有限时间内可以获得最高的收集效率。
其他
有关引用
假设有一句代码:
Object obj = new Object();
代码中,“Object obj”这部分的语义将会反映到JVM运行时数据区的虚拟机栈的局部变量表中,作为一个reference对象出现。
由于Java虚拟机规范中,只规定了reference类型是一个指向对象的引用,并没有定义这个引用该如何去定位和访问堆中对象,因此不同的虚拟机可以有不同的实现访问方式。
主流的访问方式有两种,分别是使用句柄和直接指针。
若使用句柄的方式,Java堆中会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据和类型数据,以及各自具体的地址信息。
若使用直接指针的方式,Java堆中对象的布局就要考虑如何放置访问类型数据的相关信息,reference中存储的就是对象的地址。
两种方式有各自的优势,使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时,只需要改变句柄中的实例数据指针,而reference本身不需要被修改;使用直接指针的方式的最大好处就是速度更快,因为针对每一个reference,它都节省了一次指针定位的开销。
而Sun HotSpot虚拟机采取的就是直接指针的方式。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这四种引用强度依次递减。
强引用是程序代码中普遍存在的,只要强引用还存在,被引用的对象永远不会被垃圾收集器回收掉。
软引用用来描述一些尚且有用,但并非必须的的对象。有软引用关联着的对象,在系统将要发生内存溢出之前,垃圾收集器才会对这些对象进行回收,如果回收之后内存依旧不足,才会抛出异常。(JDK1.2之后,可使用SoftReference类来实现软引用)
弱引用也是用来描述一些非必须的对象,但要比软引用的强度要弱些,有弱引用关联着的对象,只能活到下一次进行垃圾收集之前。当垃圾收集器进行工作时,无论当前内存是否足够,都会讲弱引用对象进行回收。(JDK1.2之后,可使用WeakReference类来实现弱引用)
虚引用也成为幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,对它的生存时间完全不构成影响,也无法通过虚引用来获得一个对象的实例。虚引用的主要作用在于,当对象存在虚引用时,在它被回收掉后,会收到一个系统通知。(JDK1.2之后,可使用PhantomReference类来实现虚引用)
有关finalize()方法
原书作者建议读者们在应用时忘掉这个方法的存在,理由是finalize()方法运行代价高昂,不确定性大(从它并不能保证一定能被Finalizer线程执行就可看出这一点),无法保证各个对象的调用顺序,而且它能做的所有工作,try-finally语句块及其他方法也能做且能比它做得更好、更及时。
而finalize()方法出现的目的,主要是Java刚诞生不久时,为了让C/C++程序员更能接受这门语言而做的一个妥协(C/C++语言需要手动为new对象配对相应的free/delete来进行内存回收)。