Java内存介绍
Java运行时数据区分为下面几个部分:
-
程序计数器;程序计数器是线程私有的,一块较小的内存空间,它的作用是作为当前线程所执行的字节码的行号指示器
-
Java虚拟机栈;虚拟机栈是线程私有的,它描述的是Java方法在执行时方法的内存模型
虚拟机栈相关的两个异常:- OutOfMemoryError;如果在动态扩展时,线程无法申请到足够的内存,或无法创建新的线程,则会抛出该异常
- StackOverflowError;如果方法请求的栈容量超过了JVM允许的最大容量(可通过-Xss参数进行设置),则会抛出该异常
栈里最重要的概念就是栈帧,栈帧是一种用于存放方法相关数据和过程数据结果的数据结构,栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息,这四个部分作用如下:
- 局部变量表,是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量;需要注意的是相对于类变量两次赋值过程(一次在准备阶段,赋予系统默认初始值,另外一次在初始化阶段,赋予程序逻辑定义的初始值),局部变量在定义的时候必须赋值,若未赋值则不能使用,所以在一个方法内部定义变量时必须赋值,否则在调用处将编译不通过
- 操作数栈,它是一个后入先出栈,在方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入/出栈操作,例如做算数运算的时候就是通过操作数栈来进行
- 动态链接,动态链接指的是指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了方法调用过程中的动态连接
- 方法返回地址,这个比较好理解,就是为了帮助方法调用完成后回到其调用处,当然异常退出的话是另外的处理机制
栈帧的大小在编译期就已经确定,不会在运行期改变,而栈帧中两个最重要的概念就是局部变量表和操作数栈,局部变量表用于存储方法参数以及方法执行过程中的局部变量(基本类型,对象引用);操作数栈用于存储方法执行过程中的参数和计算结果
-
本地方法栈;本地方法栈与Java虚拟机栈很相似,主要的不同点在于本地方法栈是给native方法使用的,另外很多虚拟机已经将Java虚拟机栈和本地方法栈合二为一,如最常用的HotSpot虚拟机
-
Java堆
Java堆是被所有线程共享的一块内存区域,他的作用就是存储Java对象;一般来说不同的线程会在堆中被分配不同的线程缓冲区,以避免多个线程使用同一块内存区域导致的竞争,这一类缓冲区被称为TLAB(Thread Local Allocation Buffer),当TLAB的空间不够时,会加锁并向堆申请新的内存空间;Java堆的大小可以设计为固定大小,也可以设计为动态扩展;如果Java堆的内存已不足且没有办法申请更多的内存,则会抛出OutOfMemoryError
异常;对象的存储模型一般有两种形式,第一种是先通过栈的 reference指向堆里的对象实例,而这个对象实例数据又有一个指针指向方法区的对象类型数据:
第二种是先通过栈的 reference指向堆里对象实例句柄池,这个句柄池里包含了指向实例池的对象实例数据和指向方法区的对象类型数据;这两种方式各有优点,第一种reference直接就指向了对象,减少了一次指针指向,而第二种reference指向的句柄池指针不会随着对象的移动而改变(Java垃圾回收时会移动对象的位置),而第二种就需要改变reference的指向
heap_2.png可以通过-Xms
和-Xmx
来设置堆内存的大小,可以通过设置-XX:+HeapDumpOnOutOfMemoryError
实现出现OutOfMemoryError
异常时将异常信息保存至磁盘中
- 方法区
方法区也称”永久代” 、“非堆”, 它用于存储类相关的信息(版本,属性,方法,接口,final常量,静态变量),是各个线程共享的内存区域。默认最小值为16MB,最大值为64MB,可以通过-XX:PermSize
和-XX:MaxPermSize
参数限制方法区的大小;在方法区中,专门划出了一部分空间作为运行时常量池,它是方法区的一部分,Class文件中除了有类的版本、属性、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中
除了上面的5个部分以外,还有一类JVM相关的内存空间称之为:直接内存;直接内存并不是虚拟机内存的一部分,也不是Java虚拟机规范中定义的内存区域。如JDK1.4中新加入的NIO,引入了通道与缓冲区的IO方式,它可以调用Native方法直接分配堆外内存,这个堆外内存就属于直接内存,直接内存的分配不会影响堆内存的大小
Java内存管理
这里说的Java内存分配主要指代的是对象的内存分配,也就是堆的运作机制;在JVM中,一般从对象存活时间角度上将存储空间分为
- 新生代,新生代由一块Eden space和一块Survivor space组成;其中Survivor space又被划分为两块:From space和To space或Survivor 0 和Survivor 1;新创建的对象都会存储在新生代中(除了一些比较大的对象会直接创建至老年代);与新生代对应的回收称之为 minor garbage collection (minor GC),当Eden space不够用时,JVM就会启动minor GC并将依旧存活的对象复制至Survivor space,经过特定的GC次数后(可以通过
-XX:MaxTenuringThreshold
设置),如果对象依然存活,则复制至老年代。
另外说说这里为什么会把Survivor space分为两个区(当然也有的JVM模型是不分的),这其实和minor GC(回收新生代对象)的复制-清除算法有关(该算法可参考本文"垃圾收集算法"章节),首先,eden区满了,触发GC,将存活对象复制到Survivor区等待其老去,待到eden区再一次满以后,GC会再次清理eden区和Survivor区,并将eden中存活的对象复制到Survivor区,若Survivor区只有一个,那么在复制前(或者后)需要对Survivor区进行碎片处理,而由于碎片处理效率不高,所以索性将Survivor区分成两个,每次都直接进行复制(eden和Survivor 0复制到Survivor 1,Survivor 0清空,下一次就是eden和Survivor 1复制到Survivor 0,Survivor 1清空),简单方便效率高 - 老年代,也称Tenured space,用于存放经过多次minor garbage collection任然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代,主要有两种情况:①.大对象,可通过启动参数设置-XX:PretenureSizeThreshold=1024(单位为字节,默认为0)来代表超过多大时就不在新生代分配,而是直接在老年代分配。②.大的数组对象,切数组中无引用外部对象。 老年代所占的内存大小为-Xmx对应的值减去-Xmn对应的值;与老年代对应的回收称之为major garbage collection(full GC),当老年代的空间不够用时,就会执行full GC;需要注意的是full GC比minor GC所需的代价要大得多
- 永久代,即方法区,永久代这个概念其实属于HotSpot发明的,其他的虚拟机大都没这个概念,另外HotSpot自身也计划在后续的发展中抛弃永久代这个概念,在Java 8中已经通过Metaspace来代替永久代
其中新生代与老年代分别处于堆中,而永久代处于方法区中
对象的生存或死亡
与内存分配对应的是内存的回收,Java的内存回收依靠的是垃圾收集器,它主要负责将已经没有存在意义的内存占用进行回收,以保证后续其他的内存使用,这里涉及到两点知识:
- 如何判断对象已经没有存在意义
- 如何进行内存回收
首先来看垃圾收集器是如何判断对象的生或死的,一般有两种方式判断一个对象是否应该继续存活:
- 引用计数算法,即有地方持有该对象的引用就在该对象的引用计数器上加1,反之,当引用失效时就减1;那么该引用计数器为0的对象就是不可能再被使用的
-
可达性分析算法,这个算法的基本思路是通过一系列称为「GC Roots」的对象作为起始点,从这些节点开始往下搜索,搜索所走过的路径称为引用链,当一个对象到「GC Roots」没有任何引用链相连(就是「GC Roots」到这个对象不可达)则证明此对象是不可用的,如下图,Object 5,6,7皆为不可达对象
image.png
在Java中作为「GC Roots」的有四类对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性应用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
Java使用的就是可达性分析算法,其实对于引用计数算法有一个比价明显的问题,就是对于两个或多个对象的循环引用,它是无能为力的;那么被可达性算法标记为不可达的对象是否就一定会被回收掉呢?其实也不是,要真正宣告一个对象的死亡,至少要经历两次标记过程:
- 如果对象在经过可达性分析发现没有与「GC Roots」相连,那么它将被第一次标记并进行一次筛选,筛选的条件是该对象是否有必要执行
finalize()
方法,如果有必要(如果重写了就需要执行)就执行,此时是可以在finalize()
中将对象进行最后的挽救的(重新对其进行引用),但是不建议这样做,一是因为这种垂死挣扎的方式没必要,二是因为Java本身并不保证finalize()
何时执行或一定能执行完成 - 经过第一步后,会再次对这些对象进行标记,若此时没有在
finalize()
中对对象进行重新引用,那么就可以标记为「确定死亡」了
最后说说方法区(或永久代)的回收,方法区的垃圾收集主要针对两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象很类似,以常量池中的字符串回收为例,如果字符串常量"abc"在当前的系统中没有一个对应的String对象对其进行引用,那么这个常量将允许被回收;而判断一个类是否是"无用的类"则较苛刻,它需要满足几个条件:
- 该类的所有实例都被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法
只有满足这三个条件,该类才允许被回收,但需要注意的是方法区的回收不是一定会执行的,而且JVM的规范里也没有强制性的要求说需要实现这一部分区域的回收机制,对于一些运用运行时代码生成技术如反射,动态代理,CGLib等这种频繁自定义ClassLoader的场景需要虚拟机具备类卸载的功能,以保证方法区不会溢出
垃圾收集算法
垃圾收集算法主要有下列几种:
-
标记-清除算法,正如它的名字一样算法分为"标记"和"清除"两个阶段,这个算法是最基础的收集算法,后续的收集算法都是基于这种思路并对其不足进行改进,它的效率不高,同时会产生大量不连续的内存碎片
image.png -
复制-收集算法,它将可用内存分为两块,每次使用其中一块,当这块内存用完了,就将还存活的对象复制到另外一块上面,然后把已使用的内存空间一次性清理掉;它的主要优点是不用考虑内存碎片的问题,但是也存在着实际使用内存变小的不足
image.png -
标记-整理算法,这种算法的过程与"标记-清除算法"类似,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
image.png - 分代收集算法,这是当前主流JVM采用的算法,这种算法没有什么新思想,只是根据对象存活周期不同将内存划分为几块(前面已有介绍),在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量的复制成本就可以完成收集,而老年代中因为对象存活率高,没有额外空间进行分配担保,就采用"标记-清除算法"或"标记-整理算法"进行回收
最后,推荐两篇关于JVM内存相关性能调优的文章
网友评论