JVM—Java内存区域
最近开始看《深入理解Java虚拟机》这本书,对于一个合格java程序员,掌握jvm尤其是GC垃圾回收技术是必须的。昨天看完了第二章——java内存区域与内存溢出异常,在这里简要总结一下。
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来
1.运行时数据区域
上图就是java虚拟机运行时的数据区,接下来我们依次学习其中的各个部分
- 1 .程序计数器: 线程私有,可看做当前线程所执行的字节码的行号指示器,也就是说:java程序在运行时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 2 .java虚拟机栈: 线程私有,生命周期与线程相同,描述java方法执行的内存模型:每个方法在执行的同时会创建一个栈帧(Stack Frame)用于存储 局部变量表,操作数栈,动态链接,方法出口等信息。实际上,每一个java方法的调用到执行完成,对应的也就是一个栈帧在虚拟机栈中的入栈和出栈。局部变量表存放了编译器可知的各种基本数据类型,对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。
- 3 .本地方法栈: 与虚拟机栈类似,不过本地方法栈为虚拟机用到的Native方法服务。
- 4 .java堆: 线程共享,用来存放对象实例,是垃圾收集器管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。
- 5 .方法区: 线程共享,用于存储区已被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等信息,相对而言,垃圾回收在这个区域是比较少出现的,但并非是“永久的”,这区域的内存回收目标主要是针对常量池的回收和类型的卸载,但因为回收条件苛刻,这部分区域的回收“成绩”并不理想。运行时常量池: 是方法区的一部分。Class文件除了有类的版本,字段,方法,接口等描述信息外还有常量池,运行时常量池相对于Class文件常量池的一个重要特征就是具备动态性,即:运行期间也可能将新的常量放入池中,例如String类的intern()方法。
JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。 - 6 . 直接内存: 并不是虚拟机运行时数据区的一部分,JDK1.4新加入了NIO类,引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
2.虚拟机中的对象
在语言层面上,创建对象通常仅仅是一个new关键字而已,在虚拟机中,对象的创建(普通对象,不包括数据和Class对象)又是怎样的过程呢?
-
1 .对象的创建: 当虚拟机遇到一条new指令时,首先去检查 1.指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载,解析和初始化过,如果没有,那必须先进行类加载。2.接下来虚拟机为新生对象分配内存,其实就是把一块确定大小的内存从java堆中划分出来。分配内存有不同的方法。指针碰撞: 如果java堆是规整的,也就是用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅需要把指针向空闲空间那边移动一段与对象大小相等的距离。空闲列表: 如果java堆的内存不是规整的,那就需要维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间分配给对象实例,并更新表上的记录。而java堆是否规整又与采用的垃圾收集器是否带有压缩整理功能决定,因此不同的虚拟机会采用不同的方案。其实除了如何划分空间,还需要考虑在高并发情况下指针的修改是否是线程安全的(因为java堆是线程共享的)。为了解决这个问题,可以通过对分配内存空间的动作进行同步处理,还有一种方案是在java堆中预先为每个线程分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB), 哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有当TLAB用完并分配新的TLAB时,才需要同步锁定。3.内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。 4. 接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息,这些信息放在对象头中。 经过上面的过程,在虚拟机的视角看,一个新的对象已经产生了,但从Java程序视角看,对象创建才刚刚开始,还需要通过< init >方法初始化后,一个对象才算真正产生。
-
2.对象的内存布局: 内存中对象的存储布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)
- 对象头:对象头包括两部分信息,第一部分用于存储对象自身运行时数据,如哈希码,GC分代年龄,锁状态标志,线程所持有的锁,偏向线程ID,偏向时间戳等。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 实例数据:是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的还是在子类中定义的,都需要记录下来。
- 对齐填充:并不是必然存在的,仅仅起着占位符的作用. 对象大小必须是8字节的整数倍
-
3.对象的访问定位: 建立对象是为了使用对象,我们的java程序需要通过栈上的reference数据来操作堆上的具体对象。但不同的虚拟机有不同的访问方式,主流的有句柄和直接指针两种
- 如果是句柄访问的话,java堆会分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息
- 直接指针访问,java堆对象的布局中就要考虑如何放置访问类型数据的相关信息,而reference中存储的就是对象地址
这两种访问方式各有优势,使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾回收时经常出现)时只会改变句柄中的实例数据指针,而reference本身不需要改变。使用直接指针的最大好处就是速度更快。