深入理解JVM总结-JDK各版本、JVC内存分配及溢出异常

第一部分 走近JAVA

第一章 走近Java

1.JDK1.5版本改动非常大,加入了自动装箱、泛型、动态注解、枚举、可变长参数以及遍历循环等。

    JDK1.6提供动态语言支持,提供API编译,且JVM中改进了锁与同步、垃圾收集以及类加载等的算法。

   JDK1.7提供新的G1收集器、加强对非Java语言的调用支持(目前未完全定型)、升级类加载架构等。

  JDK1.8增加Lambda表达式、Jigsaw(未实现)。Lambda用来进行函数式编程。

不采用Lambda的老方法:
	Runnable runnable1=new Runnable(){
	@Override
		public void run(){
			System.out.println("RunningwithoutLambda");
		}
	};
使用Lambda:
	Runnable runnable2=()->{
		System.out.println("RunningfromLambda");
	};

2.目前Java程序在64位虚拟机上运行需要付出较大的额外代价:一是内存问题,由于指针膨胀和各种数据类型对齐补白等原因,64位系统上通常比32位多消耗10%~30%内存。其次测试来看,64位虚拟机的运行速度几乎全面落后于32位大概15%左右的性能差距。但在J2EE方面,企业级应用经常使用超过4GB的内存,因此对于64位虚拟机的需求非常迫切。

第二部分 自动内存管理机制

第二章 Java内存区域与内存溢出异常

深入理解JVM总结-JDK各版本、JVC内存分配及溢出异常_第1张图片深入理解JVM总结-JDK各版本、JVC内存分配及溢出异常_第2张图片

上图为JVM运行时数据区,其中方法区和堆由所有线程共享的数据区,其余的为线程隔离的数据区。

1.程序计数器(Program Counter Register),一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器。字节码解释器的工作就是改变这个计数器的值来选择下一条需要执行的字节码指令,分支、循环、跳转、异常、线程恢复等基础功能都需要依赖这个计数器来完成。

在任一确定时刻,一个处理器(或多核的一个内核)都只会执行一条线程中的指令。同时为了线程切换后能恢复到正确的执行位置,每条线程都需要一个相互之间并不影响的独立的程序计数器,独立存储,这类内存区域为“线程私有”的内存。

若线程是正在执行Java方法,则计数器记录的是正在执行的JVM字节码指令的地址;若正在执行的是native方法,则计数器值为空。

此内存区域是唯一一个在JVM规范中没有任何规定OutOfMemoryError情况的区域。

2.Java虚拟机栈(JVM Stack):线程私有的,生命周期与线程相同。JVM Stack描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法的调用到执行完的过程即为入栈到出栈的过程。局部变量表存放的是各种基本数据类型、对象的引用和returnAddress类型(指向了一条字节码指令的地址)。其中long与double占两个局部变量空间。其余的只占一个。局部变量表的空间在编译时就完成了分配,进入方法时在帧中的空间分配是确定的,在方法运行期间不会改变局部变量表的大小。若线程请求栈深度大于JVM允许的深度,则抛出StackOverflowError异常,若JVM Stack可扩展,却无法申请到足够的内存时,抛OutOfMemoryError异常。

3.本地方法栈(Native Method Stack):类似于虚拟机栈。区别是本地方法栈为JVM使用到的native方法服务,而JVM Stack为Java方法,即字节码服务。也会抛出虚拟机栈的异常。

4.Java堆(Java Heap):JVM内存管理最大的一块。线程共享。JVM启动时创建唯一目的就是存放对象实例以及数组Java堆是垃圾回收管理的主要区域。也被称为GC堆(Garbage Collected Heap)。由于现在收集器基本上都采用分代回收算法,因此Java堆又可以分为年轻代和年老代以及持久代。

年轻代:是所有新对象产生的地方。年轻代被分为3个部分——Eden区和两个Survivor区(From和to)当Eden区被对象填满时,就会执行Minor GC。并把所有存活下来的对象转移到其中一个survivor区(假设为from区)。Minor GC同样会检查存活下来的对象,并把它们转移到另一个survivor区(假设为to区)。这样在一段时间内,总会有一个空的survivor区。经过多次GC周期后,仍然存活下来的对象会被转移到年老代内存空间。通常这是在年轻代有资格提升到年老代前通过设定年龄阈值来完成的。需要注意,Survivor的两个区是对称的,没先后关系,from和to是相对的。
年老代:在年轻代中经历了N次回收后仍然没有被清除的对象,就会被放到年老代中,可以说他们都是久经沙场而不亡的一代,都是生命周期较长的对象。对于年老代和永久代,就不能再采用像年轻代中那样搬移腾挪的回收算法,因为那些对于这些回收战场上的老兵来说是小儿科。通常会在老年代内存被占满时将会触发Full GC,回收整个堆内存。
持久代:用于存放静态文件,比如java类、方法等。持久代对垃圾回收没有显著的影响。

Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。若堆中没有内存分配空间且无法扩展时,将抛OutOfMemoryError异常。

5.方法区(Method Area):线程共享的。用于存储已被JVM加载的类信息(类名,访问修饰符,字段描述,方法描述等)、常量、静态变量、即时编译器编译后的代码等数据。(非堆)不需要连续的内存,可选择固定大小或可扩展,还可以选择不实现垃圾收集这部分区域的内存回收目标主要是针对常量池的回收以及对类型的卸载,但回收效果一般,尤其是类型的卸载条件非常苛刻。方法区内存无法满足需求时,抛出OutOfMemoryError异常。

6.运行时常量池(Runtime Constant Pool):方法区的一部分。常量池,用于存放class文件编译期生成的各种字面量和符号引用除了符号引用以外,直接引用也会存储在常量池中。常量池另一个特征是具备动态性,运行期新的常量也可以进入放入常量池中,如string类的intern()方法等。

7.直接内存(Direct Memory):不是JVM运行时数据区的一部分,也不是JVM规范中定义的内存区域,但也在被频繁使用,也可能导致OutOfMemoryError异常出现。JDK1.4新产生的new Input/Output引入的基于通道与缓冲区的I/O方式,可以使用native函数库直接分配堆外内存,通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。本地直接内存不会受到Java堆的大小限制,但会受到本机总内存(RAM/SWAP区或分页文件)大小以及处理器寻址空间的限制。-Xmx设置不合理也会导致OutOfMemoryError异常出现。

8.JVM中对象的创建、布局与访问

    8.1 对象的创建

①检测对象是否加载完毕

JVM通过new先去检查是否在常量池中有对应的符号引用,且检查这个符号引用所代表的类是否被加载、解析和初始化过。没有的话,则必须执行相应的类加载过程。

为对象分配堆内存

类加载检测通过之后,JVM为新生对象分配内存。类加载后对象所需内存即确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来

分配方法分为“指针碰撞”和“空闲列表。假设堆内存绝对规整,用过的放一边,空闲的放一边,中间为指针进行分隔。指针碰撞即指针向空闲的一方移动大小为对象的内存空间。假设堆内存不完全规整,使用过的和空闲的交错,JVM必须维护一个列表记录哪些是空闲的,在分配时找到足够大的空间分配给对象实例,并更新列表的记录。这种分配方式是“空闲列表”。

Java堆是否规整由垃圾收集器是否带有压缩整理功能决定。

例如:Serial/ParNew等带compact过程的收集器是指针碰撞,而CMS这种基于Mark-Sweep算法的收集器采用空闲列表。

除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常 频繁的行为,即使是仅仅修改一个指针

所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原

来的指针来分配内存的情况。解决方法:一、堆分配内存空间的动作进行同步处理;二是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(本地线程分配缓冲Thread Local Allocation Buffer,TLAB,解决线程不安全的问题)。哪个线程要分配,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

分配到的内存空间初始化为零值(不包括对象头)

保证了对的实例字段在Java代码中可以不赋初始值就可以直接使用,程序能访问这些字段的数据类型所对应的零值。

④对对象进行必要的设置(即设置对象头Object Header)

例如对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。存放在对象头当中。

通过以上四步,从JVM角度来看,一个新的对象就产生了,但从Java程序来说,对象创建才刚刚开始——方法还没有执行,所有的字段均为零。

因此,执行完new之后,会接着执行方法,把对象初始化,这样一个真正可用的对象才算完全产生。

  8.2对象的布局

对象在内存中的布局分为3个部分:对象头Header、实例数据Instance Data和对齐填充Padding

对象头包含两部分信息:用于存储对象自身的运行时数据以及类型指针

  • 存储对象自身的运行时数据 
    • 内容举例: 
      • 哈希码HashCode
      • GC分代年龄
      • 锁状态标识
      • 线程持有的锁等
    • 长度(未开启压缩指针下):32 or 64 位的虚拟机中分别为 32bit or 64 bit
    • 官方名称:Mark Word
    • 非固定数据结构: 
      • 考虑到存储效率,已让其在及小空间内存储更多信息
      • 也就是说,在这么多bit下,哪几位存储哪些内容是不定的
  • 类型指针 
    • 作用:对象指向它的类元数据的指针,JVM通过此来确定该对象是哪个类的实例 
      • 并非所有JVM的实现都需要这个指针,也就是对象的元数据查找并不一定要经过对象本身

注意:对于数组而言,对象头中还会记录数组的长度。JVM可以通过对象的元数据信息确定Java对象的大小。

但从数组对象的元数据中是无法获取数组大小的。

实例数据是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

无论是父类继承下来的还是子类中定义的

存储顺序(HotSpot) 

  • 策略:同宽度相同的分配到一起
  • 具体分配方式: 
    • longs/doubles
    • ints
    • shorts/chars
    • bytes/booleans
    • oops(Ordinary Object Pointers)
对齐填充并不是必然存在的,也没啥特别含义,仅仅起着占位符的作用。JVM要求对象的起始地址必须是8字节的整数倍,
即对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的1倍或2倍,因此当对象实例数据部分没有对齐时,就需要通过对
齐填充来补全。

8.3对象的访问定位
Java程序通过栈上的引用reference数据来操作堆上的具体对象。
①使用句柄
Java堆将划分一块内存作为句柄池, reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的
具体地址信息 最大的好处是reference中存储的是稳定的句柄地址,在对象被移动(GC过程中)时只会改变句柄中的实例数据指针,而
不会改变reference本身。
深入理解JVM总结-JDK各版本、JVC内存分配及溢出异常_第3张图片
②直接指针
Java堆对象的布局中得考虑如何放置类型数据的相关信息,而reference中存储的直接就是对象的地址
这种方式的好处在于速度更快,节省了一次指针定位的时间开销 。由于对象访问十分频繁,因此这类开销积少成多后是一项非常可观的
执行成本。
对于Sun HotSpot虚拟机来说,主要还是使用第二种方式。
深入理解JVM总结-JDK各版本、JVC内存分配及溢出异常_第4张图片
9.内存溢出异常OutOfMemoryError
除程序计数器以外都可能发生溢出异常。
9.1 Java堆溢出
只要不断创建对象,且保证GC Roots到对象之间有可达路径来避免GC机制清除对象,那么在对象数量达到最大堆的容量限制后就会产
生内存溢出异常。(将堆的最小值与最大值设置为一样的即不可扩展)
import java.util.ArrayList;
import java.util.List;

public class Test4 {
	/*VM args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
	 * */
	static class OOMObject{
		
	}
	public static void main(String[] args) {
		List list=new ArrayList();
		while(true){
			list.add(new OOMObject());
		}
	}
}
解决一般是通过内存映像分析工具分析到底是内存泄漏还是内存溢出。
若是泄漏,可查看泄漏对象到GC Roots的引用链,可以比较准确的定位出泄漏代码的位置。
若是溢出,即检查虚拟机堆参数,是否可以适当调大,从代码上可以检查是否存在某些对象生命周期过长、持有时间太长,尝试减少
运行时期内存消耗。
=====================================================================
所谓内存泄露就是指一个不再被程序使用的对象或变量一直被占据在内存中。
产生原因:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生
命周期对象持有它的引用而导致不能被回收。通俗地说,就是程序员可能创建了一个对象,以后一直不再使用这个对象,这个对象却一直
被引用,即这个对象无用但是却无法被垃圾回收器回收的,这就是java中可能出现内存泄露的情况。
发生场景:1,缓存系统,我们加载了一个对象放在缓存中(例如放在一个全局map对象中),然后一直不再使用它,这个对象一直被缓
存引用,但却不再被使用。
2,如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被
使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
3,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与
最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中
检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。
注意:由于Java 使用有向图的方式进行垃圾回收管理,可以消除引用循环的问题,例如有两个对象,相互引用,只要它们和根进程不可达
的,那么GC也是可以回收它们的。例如对象A和对象B相互引用对方,GC照样可以回收
===========================================================
9.2 虚拟机栈和本地方法栈溢出
如果线程请求栈的深度大于JVM最大深度,则抛出栈溢出异常;若JVM在扩展时无法申请到足够的空间,则抛出内存溢出异常。
在单个线程下,内存无法分配是,虚拟机抛出的都是栈溢出异常。
每个线程分配的栈容量越大,可以建立的线程数量就越少,建立线程时就越容易把剩下的内存耗尽。
JVM默认深度为1000-2000,一般来说够用。
若是多线程情况下无法减少线程数,就只能通过减少最大堆和减少栈容量来换取更多的线程。
VM args:-Xss128k(虚拟机栈和本地方法栈) VM args: -Xss2M(可以设大点,创建线程导致溢出)
9.3方法区和运行时常量池溢出
运行时常量池是方法区的一部分。
String.intern()是一个native方法,表示如果字符串常量池中已经包含一个等于此String的字符串,则返回代表池中这个字符串的对象;
否则将此字符串添加到常量池中,并返回此string字符串对象的引用。
由于常量池分配在永久代中,可以通过-XX:PermSize和-XX:MaxPermSize来限制方法区大小,从而间接限制其中常量池的容量
VM args: -XX:PermSize=10M-XX:MaxPermSize=10M
在方法区,一个类要被回收判定的条件是十分苛刻的。在经常动态生成大量class的应用中,需要特别注意类的回收情况。如大量JSP等
9.4本地直接内存溢出
通过-XX:MaxDirectMemorySize来指定。若不指定,则默认和Java堆最大值一样。
本地直接内存溢出抛异常时,并没有真正向操作系统申请分配内存,而是通过计算机得知内存无法分配,于是手动抛出,真正申请分配
内存的方法时unsafe.allocateMemory()。
本地直接内存溢出的明显特征是在Heap Dump文件中不会看见明显的异常,如果异常之后Dump小了,而程序又直接或间接使用NIO,
则可以考虑是否是本地直接内存溢出。

你可能感兴趣的:(深入理解JVM)