Java内存分配与垃圾回收(一)

时间:2022-02-01 00:02:03

写在前面:主要为《深入理解Java虚拟机》的读书笔记,加上自己的思考,本篇主要讲内存分配,图片来自网络,侵删。

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外的人想进来,墙里面的人却想出来。


一、概述

  • C/C++
    • 既拥有最高权力:拥有每个对象的所有权;
    • 又担负责任:管理对象从创建到终结的生命周期。
  • Java
    • 虚拟机自动内存管理机制完成对象的创建和垃圾回收;
    • 一般情况下,java程序员只需要调用new就可以创建对象;
    • 优点:操作简单,不容易发生内存泄露和内存溢出问题;
    • 缺点:一旦出现内存泄露和溢出问题,需要了解虚拟机内存管理的机制来排查错误。

二、对象的创建

在语言层面,创建对象只需要new关键字(普通对象,不包括数组等)即可,那么在虚拟机底层,对象又如何创建的呢?

(1)虚拟机遇到new指令时,先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化过。若没有,则先执行相应类的加载过程。

(2)为新生对象分配内存,对象所需内存大小在类加载完即可完全确定。这一过程相当于把一块确定大小的内存从Java堆中划分出来。

  • 指针碰撞分配
    如果内存是绝对规整的,即左右两边分别是已占用内存和闲置内存,中间有分界点的指针指示器,那么内存分配仅仅在于指针的移动,这种分配方式叫做“指针碰撞”。

  • 空闲列表分配
    如果内存不规整,即已使用和空闲的内存交错分布,那么虚拟机必须维护一个列表,记录哪些内存可用。创建对象时从列表中找到一块足够大的空间划分给对象使用,同时更新列表记录。这种分配方式称为“空闲列表”。

    以上两种分配取决于内存是否规整,二内存是否规整又由垃圾收集器是否带有压缩整理功能决定。

    此外,即便仅仅修改指针所指向的位置,在并发情况下也不是线程安全的(如,可能两个线程拿一个指针起始位置来分配内存),有两种解决方案:

  • 对分配内存空间的动作进行同步处理
    虚拟机采用CAS(Compare and Swap,多线程操作中只有一个线程能成功,其他线程被通知竞争失败)配上失败重试的方式保证操作的原子性。

  • 使用本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
    每个线程在Java堆中预先分配一小块内存,各个线程的内存分配发生在自己的TLAB区域内,只有TLAB用完需要分配新的空间时才需要同步锁定。虚拟机是否使用TLAB,可通过-XX:+/-UseTLAB参数设定。

(3)内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作可提前至TLAB分配时进行。这一操作保证了Java中对象的实例不赋值即可使用(如C语言中必须初始化否则报错)。

(4)虚拟机对对象进行必要的设置,如对象头、元数据等信息进行设置。

(5)执行init,按程序员的意愿进行对象初始化。


三、对象在内存中如何存在?

在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(padding)。

  • 对象头

    • 第一部分:存储对象自身运行时数据,官方称为“Mark Word”,如:
      • 哈希码HashCode
      • GC分代年龄
      • 锁状态标识
      • 线程持有的锁
      • 偏向线程ID
    • 第二部分:类型指针,即对象指向它的类元数据的指针。
      虚拟机通过这个指针确认属于哪个类的实例,然而并不是所有的虚拟机都保存类型指针。

    • 如果对象为Java数组,还需要记录数组长度。因为普通对象可以从元数据信息确定对象大小。

  • 实例数据
    对象真正存储的数据,包括父类继承的数据和子类中自定义的数据。
    HotSpot虚拟机分配策略:相同宽度的字段总被分配到一起。

  • 对齐填充
    对齐填充并不是必须,仅仅是占位符的作用。
    HotSpot自动内存管理要求对象的起始地址必须是8字节的整数倍,而对象头都正好是一倍或两倍,因此,当实例数据部分没有对齐时,需要对齐填充来补全。

四、Java内存数据区域

Java内存分配与垃圾回收(一)

  • 程序计数器

线程私有,是当前线程执行行号的指示器,一块较小的内存空间。

Java通过线程轮流切换并分配cpu时间片的方式实现多线程,为了线程切换后能够恢复到正确的位置,每条线程内都有一个独立的程序计数器记录当前运行位置。
如果当前执行的为Native方法,则计数器为空(Undefined),因为计数器只记录行号,因此不会内存溢出。因此,程序计数器是在Java虚拟机规范中唯一一个没有规定OutOfMemoryError情况的区域。

  • java虚拟机栈(Java方法栈 )

线程私有,其生命周期与线程相同。

虚拟机栈描述的是Java方法执行的内存模型:每个方法执行时都会创建一个栈帧(存储局部变量表、操作数表、动态链接、方法出口等),方法被调用到完成对应着栈帧在虚拟机中从入栈到出栈的过程。
局部变量表包括各种基本数据类型和对象引用类型(reference,不是对象,可能是指向对象初始位置的指针,也可能指向一个代表对象的句柄或其他与此对象相关的位置)。
可能的异常:
①*Error
如果线程请求的栈深度大于虚拟机所允许的深度,则发生此异常。
②OutOfMemoryError
虚拟机栈可动态扩展,如果扩展无法申请到足够的内存,则发生此异常。

  • 本地方法栈

本地方法栈与虚拟机栈类似,区别在于本地方法栈为使用到的Native方法服务,而虚拟机栈为用到的Java方法服务。

有些虚拟机(如hotspot虚拟机)把虚拟机栈和本地方法栈合二为一,本地方法栈抛出的异常与虚拟机栈同。

  • Java堆

线程共享,是Java虚拟机中内存占用最大的一块,存储对象实例和数组;
是垃圾回收的主要管理区域,因而又称“GC堆”。

绝大部分对象和数组都是在java堆上分配(随着一些技术的发展,所有对象在堆上分配变得没有那么绝对了),但对象的引用却在栈中(reference)。

Java堆进一步细分:
①从内存回收角度:
现在收集器基本上采用分代收集算法,细分为新生代(Eden空间、From Survivor空间、To Survivor空间)和老年代。
②从内存分配:
线程共享的Java堆可能划分出多个线程私有的本地线程分配缓冲区TLAB。
进一步划分目的是,为了更快的分配内存和更好的回收内存。

当堆无法再扩展时,可能抛出OutOfMemoryError。

  • 方法区

线程共享,存储虚拟机加载的类信息(类元数据)、常量(final)、静态变量 ,即时编译器编译的代码等数据

在hotspot虚拟机中,更习惯称之为“永久代”,然而其他虚拟机并不存在永久代的概念。

除了和Java堆一样,不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾回收。
相对而言,垃圾回收在此区域发生的比较少,方法区垃圾回收一般针对常量池的回收和对类型的卸载(条件比较苛刻)。

方法区中有一个比较重要的部分是“运行时常量池”。
Class文件中除了类的版本、字段、方法、接口等信息外,还有常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载进入方法区的运行时常量池中存放。虚拟机为每个被装载的类型维护一个常量池,池中为该类型所用常量的一个有序集合,包括直接常量(string、integer和float常量)和对其他类型、字段和方法的符号引用。

关于常量池:
①包括类、方法、接口中的常量,也包括字符串常量。
②动态性:Java语言不要求常量一定在编译器产生,运行时也可以有新的常量放入常量池中,比较多的是String类的intern()方法。

当方法区无法满足内存分配时,可能抛出OutOfMemoryError。

  • 直接内存

并不是虚拟机运行时数据区的一部分,是除JVM以外的机器内存,Native函数库一般分配在直接内存里面

显然,直接内存不受Java堆大小的限制。但既然是内存,肯定会受到本机总内存大小的限制,动态扩展时还是可能发生OutOfMemoryError异常。

总结:
1)Java方法栈和本地方法栈
线程创建时产生,方法执行时生成栈帧。
2)Java堆
java代码中所有的new操作。
3)方法区
存储类的元数据信息和常量等。

Java内存分配与垃圾回收(一)


五、对象的访问定位

Java中需要根据栈上的reference数据来操作堆上的对象。reference类型只是规定了一个指向对象的引用,而这个引用以何种方式去定位,主流的有两种访问方式:句柄和直接指针。

  • 句柄访问
    句柄访问方式,会在Java堆中划分一块内存作为句柄池,Java栈中的reference存储对象所对应句柄的地址,而句柄中包含了对象的实例数据的地址和类型数据的地址信息。其实这是一个二级访问的过程。
    优点:当对象移动时,reference保存的句柄地址不用改变,只需要修改句柄中实例数据的地址信息。
    缺点:速度慢,两次指针定位开销大。
    Java内存分配与垃圾回收(一)
  • 直接访问
    直接访问,reference中存放的就是对象在堆中的实际地址。
    优点:速度快,直接定位,因而hotspot采用此种方式。
    Java内存分配与垃圾回收(一)

    如下,是一个二维数组可能的内存分布。ar作为reference存在Java栈中。
    Java内存分配与垃圾回收(一)


六、关于常量池的实践

  • Java中实现常量池的基本类型的包装类有6种:Integer、Long、Short、Character、Byte、Boolean.
    前五中默认创建了[-128,127]的缓存数据,超过此范围仍需创建新的对象。
        Integer integer1 = 40;
Integer integer2 = 40;
Integer integer3 = 128;
Integer integer4 = 128;
Integer integer5 = new Integer(40);
Integer integer6 = new Integer(0) + integer5;


System.out.println(integer1 == integer2);//true
System.out.println(integer3 == integer4);//false
System.out.println(integer1 == integer5);//false
System.out.println(integer1 == integer6);//true

Java内存分配与垃圾回收(一)
分析:
1) Integer integer1 = 40,直接使用常量池对象。
2)Integer integer3 = 128,超出范围,因此创建了新对象。
3) Integer integer5 = new Integer(40),通过new方法创建了新对象。
4)Integer integer6 = new Integer(0) + integer5,这个过程中发生了自动拆箱, integer6 = 40.

  • 浮点数类型Double、Float没有实现常量池技术
        Double d1 = 10.32;
Double d2 = 10.32;
System.out.println(d1 == d2);//false

Float f1 = 1.1f;
Float f2 = 1.1f;
System.out.println(f1 == f2);//false
  • String与常量池
        String a = "abc";
String b = "abc";
String c = new String("abc");
String d = "a"+"bc";

String e1 = "a";
String e2 = "bc";
String e = e1+e2;
final String e3 = "bc";
String e4 = "a"+e3;


System.out.println(a == b); //true

System.out.println(a == c); //false

System.out.println(a == d); //true

System.out.println(a == e);//false

System.out.println(a == e4); //true

Java内存分配与垃圾回收(一)
分析:
1)String a = “abc”,直接在常量池拿对象。
2)String c = new String(“abc”),只要使用new方法,便需要创建新的对象。
3)String d = “a”+”bc”,JVM对于字符串常量的”+”号连接,在程序编译期,JVM就将常量字符串的”+”连接优化为连接后的值,因此d被编译器优化为abc,所以是字符串常量。
4)String e = e1+e2, JVM对于字符串引用,引用的值在程序编译期是无法确定的,所以会动态分配创建新对象(和Integer不同,Integer会发生自动拆箱)。
5)e3定义为final类型,编译时将会作为常量存放在常量池中,因此“a”+e3 效果等同于“a”+“bc”。

String s = new String(“xyz”); 创建了几个对象??(答案为:1或2(个人认为))
考虑类加载阶段和实际执行时。
①类加载阶段(0或1)
类加载对一个类只执行一次。如果”abc”在类加载时已经创建并存在于常量池中,则不需要再创建。
②实际执行时(1个)
通过new String(String)创建并初始化的、内容与”xyz”相同的实例。

关于java.lang.String.intern():
前面有提到过,运行时常量池具有动态性,可在运行时产生常量添加到常量池中。String.intern()方法就是一种很常见的情况。
String的intern()方法会查找在常量池中是否存在一份内容相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。

        String s1 = "abc";
String s2 = s1.intern();
System.out.println(s1 == s2);//true

String s3 = new String("abc");
String s4 = s3.intern();
System.out.println(s3 == s4);//false

参考链接:
http://wiki.jikexueyuan.com/project/java-vm/storage.html
http://blog.csdn.net/gaopeng0071/article/details/11741027
http://blog.csdn.net/shimiso/article/details/8595564
https://my.oschina.net/xiaohui249/blog/170013
http://www.jianshu.com/p/c7f47de2ee80