-
方法区
-
堆区
-
本地方法栈
-
虚拟机栈
-
程序计数器
其中,方法区和堆是所有线程共享的。
2.1 方法区
方法区存放了要加载的类的信息(如类名,修饰符)、类中的静态变量、final定义的常量、类中的field、方法信息,当开发人员调用类对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区。方法区是全局共享的,在一定条件下它也会被GC。当方法区使用的内存超过它允许的大小时,就会抛出OutOfMemory:PermGen Space异常。
在Hotspot虚拟机中,这块区域对应的是Permanent Generation(持久代),一般的,方法区上执行的垃圾收集是很少的,因此方法区又被称为持久代的原因之一,但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。在方法区上进行垃圾收集,条件苛刻而且相当困难,关于其回后面再介绍。
运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量,比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址。
JVM方法区的相关参数,最小值:–XX:PermSize;最大值 --XX:MaxPe 需要zi料+ 绿色徽【vip1024b】
rmSize。
2.2 堆区
堆区是理解JavaGC机制最重要的区域。在JVM所管理的内存中,堆区是最大的一块,堆区也是JavaGC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区用来存储对象实例及数组值,可以认为java中所有通过new创建的对象都在此分配。
对于堆区大小,可以通过参数-Xms和-Xmx来控制,-Xms为JVM启动时申请的最新heap内存,默认为物理内存的1/64但小于1GB;-Xmx为JVM可申请的最大Heap内存,默认为物理内存的1/4但小于1GB,默认当剩余堆空间小于40%时,JVM会增大Heap到-Xmx大小,可通过-XX:MinHeapFreeRadio参数来控制这个比例;当空余堆内存大于70%时,JVM会减小Heap大小到-Xms指定大小,可通过-XX:MaxHeapFreeRatio来指定这个比例。对于系统而言,为了避免在运行期间频繁的调整Heap大小,我们通常将-Xms和-Xmx设置成一样。
为了让内存回收更加高效(后面会具体讲为何要分代划分),从Sun JDK 1.2开始对堆采用了分代管理方式,如下图所示:
年轻代(Young Generation)
对象在被创建时,内存首先是在年轻代进行分配(注意,大对象可以直接在老年代分配)。当年轻代需要回收时会触发Minor GC(也称作Young GC)。
年轻代由Eden Space和两块相同大小的Survivor Space(又称S0和S1)构成,可通过-Xmn参数来调整新生代大小,也可通过-XX:SurvivorRadio来调整Eden Space和Survivor Space大小。不同的GC方式会按不同的方式来按此值划分Eden Space和Survivor Space,有些GC方式还会根据运行状况来动态调整Eden、S0、S1的大小。
年轻代的Eden区内存是连续的,所以其分配会非常快;同样Eden区的回收也非常快(因为大部分情况下Eden区对象存活时间非常短,而Eden区采用的复制回收算法,此算法在存活对象比例很少的情况下非常高效,后面会详细介绍)。
如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java Heap Space异常。
老年代(Old Generation)
老年代用于存放在年轻代中经多次垃圾回收仍然存活的对象,可以理解为比较老一点的对象,例如缓存对象;新建的对象也有可能在老年代上直接分配内存,这主要有两种情况:一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。此参数在年轻代采用Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;另一种为大的数组对象,且数组对象中无引用外部对象。
当老年代满了的时候就需要对老年代进行垃圾回收,老年代的垃圾回收称作Major GC(也称作Full GC)。
老年代所占用的内存大小为-Xmx对应的值减去-Xmn对应的值。
2.3 本地方法栈(Native Method Stack)
本地方法栈用于支持native方法的执行,存储了每个native方法调用的状态。本地方法栈和虚拟机方法栈运行机制一致,它们唯一的区别就是,虚拟机栈是执行Java方法的,而本地方法栈是用来执行native方法的,在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。
2.4 程序计数器(Program Counter Register)
程序计数器是一个比较小的内存区域,可能是CPU寄存器或者操作系统内存,其主要用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。 每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。
如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。
2.5 虚拟机栈(JVM Stack)
虚拟机栈占用的是操作系统内存,每个线程都对应着一个虚拟机栈,它是线程私有的,而且分配非常高效。一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。
局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。
虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出OutOfMemoryError(内存溢出)。
2.6 Java对象访问方式
一般来说,一个Java的引用访问涉及到3个内存区域:JVM栈,堆,方法区。以最简单的本地变量引用:Object objRef = new Object()为例:
Object objRef 表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据;
new Object()作为实例对象数据存储在堆中;
堆中还记录了能够查询到此Object对象的类型数据(接口、方法、field、对象类型等)的地址,实际的数据则存储在方法区中;
在Java虚拟机规范中,只规定了指向对象的引用,对于通过reference类型引用访问具体对象的方式并未做规定,不过目前主流的实现方式主要有两种:
2.6.1 通过句柄访问
通过句柄访问的实现方式中,JVM堆中会划分单独一块内存区域作为句柄池,句柄池中存储了对象实例数据(在堆中)和对象类型数据(在方法区中)的指针。这种实现方法由于用句柄表示地址,因此十分稳定。
2.6.2 通过直接指针访问
通过直接指针访问的方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在HotSpot虚拟机中用的就是这种方式。
三、JVM内存分配
Java对象所占用的内存主要在堆上实现,因为堆是线程共享的,因此在堆上分配内存时需要进行加锁,这就导致了创建对象的开销比较大。当堆上空间不足时,会出发GC,如果GC后空间仍然不足,则会抛出OutOfMemory异常。
为了提升内存分配效率,在年轻代的Eden区HotSpot虚拟机使用了两种技术来加快内存分配 ,分别是bump-the-pointer和TLAB(Thread-Local Allocation Buffers)。由于Eden区是连续的,因此bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;而对于TLAB技术是对于多线程而言的, 它会为每个新创建的线程在新生代的Eden Space上分配一块独立的空间,这块空间称为TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行情况计算而得。可通过-XX:TLABWasteTargetPercent来设置其可占用的Eden Space的百分比,默认是1%。在TLAB上分配内存不需要加锁,一般JVM会优先在TLAB上分配内存,如果对象过大或者TLAB空间已经用完,则仍然在堆上进行分配。因此,在编写程序时,多个小对象比大的对象分配起来效率更高。可在启动参数上增加-XX:+PrintTLAB来查看TLAB空间的使用情况。
对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Minor GC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫 Full GC。
可以使用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。
如果对象比较大(比如长字符串或大数组),年轻代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。用 -XX:PretenureSizeThreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。
四、内存的回收方式
JVM通过GC来回收堆和方法区中的内存,这个过程是自动执行的。说到Java GC机制,其主要完成3件事:确定哪些内存需要回收;确定什么时候需要执行GC;如何执行GC。JVM主要采用收集器的方式实现GC,主要的收集器有引用计数收集器和跟踪收集器。
4.1 引用计数收集器
引用计数器采用分散式管理方式,通过计数器记录对象是否被引用。当计数器为0时,说明此对象已经不再被使用,可进行回收,如图所示:
在上图中,ObjectA释放了对ObjectB的引用后,ObjectB的引用计数器变为0,此时可回收ObjectB所占有的内存。
引用计数器需要在每次对象赋值时进行引用计数器的增减,他有一定消耗。另外,引用计数器对于循环引用的场景没有办法实现回收。例如在上面的例子中,如果ObjectB和ObjectC互相引用,那么即使ObjectA释放了对ObjectB和ObjectC的引用,也无法回收ObjectB、ObjectC,因此对于java这种会形成复杂引用关系的语言而言,引用计数器是非常不适合的,SunJDK在实现GC时也未采用这种方式。
4.2 跟踪收集器
跟踪收集器采用的为集中式的管理方式,会全局记录数据引用的状态。基于一定条件的触发(例如定时、空间不足时),执行时需要从根集合来扫描对象的引用关系,这可能会造成应用程序暂停。主要有复制(Copying)、标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)三种实现算法。
复制(Copying)
复制采用的方式为从根集合扫描出存活的对象,并将找到的存活的对象复制到一块新的完全未被使用的空间中,如图所示:
复制收集器方式仅需要从根集合扫描所有存活对象,当要回收的空间中存活对象较少时,复制算法会比较高效(年轻代的Eden区就是采用这个算法),其带来的成本是要增加一块空的内存空间及进行对象的移动。
标记-清除(Marking-Deleting)
标记-清除采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未标记的对象,并进行清除,标记和清除过程如下图所示:
上图中蓝色的部分是有被引用的存活的对象,褐色部分没被引用的可回收的对象。在marking阶段为了mark对象,所有的对象都会被扫描一遍,扫描这个过程是比较耗时的。
清除阶段回收的是没有被引用的对象,存活的对象被保留。内存分配器会持有空闲空间的引用列表,当有分配请求时会查询空闲空间引用列表进行分配。
标记-清除动作不需要进行对象移动,且仅对其不存活的对象进行处理。在空间中存活对象较多的情况下较为高效,但由于标记-清除直接回收不存活对象占用的内存,因此会造成内存碎片。
标记-压缩(Mark-Compact)
标记-压缩和标记-清除一样,是对活的对象进行标记,但是在清除后的处理不一样,标记-压缩在清除对象占用的内存后,会把所有活的对象向左端空闲空间移动,然后再更新引用其对象的指针,如下图所示:
很明显,标记-压缩在标记-清除的基础上对存活的对象进行了移动规整动作,解决了内存碎片问题,得到更多连续的内存空间以提高分配效率,但由于需要对对象进行移动,因此成本也比较高。
五、虚拟机中的GC过程
最后
每年转战互联网行业的人很多,说白了也是冲着高薪去的,不管你是即将步入这个行业还是想转行,学习是必不可少的。作为一个Java开发,学习成了日常生活的一部分,不学习你就会被这个行业淘汰,这也是这个行业残酷的现实。
如果你对Java感兴趣,想要转行改变自己,那就要趁着机遇行动起来。或许,这份限量版的Java零基础宝典能够对你有所帮助。
本也比较高。
五、虚拟机中的GC过程
最后
每年转战互联网行业的人很多,说白了也是冲着高薪去的,不管你是即将步入这个行业还是想转行,学习是必不可少的。作为一个Java开发,学习成了日常生活的一部分,不学习你就会被这个行业淘汰,这也是这个行业残酷的现实。
如果你对Java感兴趣,想要转行改变自己,那就要趁着机遇行动起来。或许,这份限量版的Java零基础宝典能够对你有所帮助。
[外链图片转存中…(img-F7Hqvt6c-1710359574117)]