JVM内存初探


JVM内存初探

目录
  • JVM内存初探
      • 1、Java 内存区域(运行时数据区)
      • 2、Java 对象的创建过程
        • 2.1、类加载检查
        • 2.2、分配内存
        • 2.3、初始化零值
        • 2.4、设置对象头
        • 2.5、执行init方法
      • 3、String 类和常量池
      • 4、8 种基本类型的包装类和常量池

1、Java 内存区域(运行时数据区)

JDK1.8之前

image-20210613093636376

? 粗略的分,可以分为堆内存、栈内存(Java虚拟机栈)、方法区(JDK1.8中的元数据)、本地方法栈、程序计数器

  • 堆内存可以细分为新生代、老年代;是比较重要的内存区域,也是GC主要的回收区域,我们创建的对象实例也就保存在这个区域中;
  • 栈内存也就是Java虚拟机栈,它是线程私有的,它的生命周期与线程相同,描述的是Java方法执行的内存模型,每次方法调用的数据都是通过Java虚拟机栈传递的;
  • 方法区,它是线程共享区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace)实现方法区,以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。
  • 本地方法栈,与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
  • 程序计数器,是线程私有的,是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器

JDK1.8之后

图摘自《码出高效》

疑问?

? 1、在堆内存中,对象是如何晋升的(新生代->老年代)

? 这是GC需要做的事情,新实例化的对象都是放在新生代的Eden区域中的。如果Eden区域满了,或者剩余空间不足以分配新的实例对象,对象会分配到老年代中(TLAB的作用下)

? 2、JDK1.8为什么用元数据 替代 永久代

? 字符串常量池存在于永久代中,在大量使用字符串的情况下,非常容易出现OOM的异常;

? JVM加载的class的总数,方法的大小 等都很难确定,因此对永久代大小的指定难以确定,元数据空间大小理论上只受限于直接内存,更加灵活;

? 详见

2、Java 对象的创建过程

2.1、类加载检查

? 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2.2、分配内存

? 经过类加载之后,我们要为新的对象分配内存,那么如何/采用什么策略进行分配呢?主要有两种方式:指针碰撞、空闲列表;那么采用那种方式呢,主要依据什么呢,主要依据堆内存是否规整(这个是由GC是否带有压缩整理功能决定)

  • 指针碰撞

    这种方式主要用于堆内存规整的情况,指针碰撞的原理是,将内存分成两个区域(使用过的 、未使用过的),这两个区域之间有一个分界指针,对于新的对象直接放入这个未使用区域即可。

    典型GC: Serial、ParNew

  • 空闲列表

    这种方式主要用于堆内存不规整的情况,空闲列表这种方式会维护一个列表,列表种记录着内存的使用情况,当要存储新的对象时,将对象存入空闲区域即可。

    典型GC: CMS

    内存分配并发问题,频繁的创建对象实例,JVM如何保证线程安全的,待完善

    • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
    • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

    详见

2.3、初始化零值

? 内存分配完成之后,虚拟机需要对这片内存区域初始化零值,这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

2.4、设置对象头

? 初始化零值之后,虚拟机要对对象进行必要的设置;对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些都保存在对象的头中;

2.5、执行init方法

对于虚拟机来说,已经产生了一个新的对象,但是从java程序的角度来看,对象的创建才刚开始,方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位的两种方式(句柄和直接指针两种方式)

  • 使用句柄

    这种方式的话,会在java堆中划分一个句柄池(指向实例的对象),对于栈帧(虚拟机栈)的局部变量表中reference指向的就是java堆中的句柄。

    对象的访问定位-使用句柄
  • 直接指针

    使用直接指针的方式,栈帧中的reference将直接指向实例对象,reference中存储也就是对象的地址。

    对象的访问定位-直接指针
  • 对比

    • 使用句柄方式,reference是稳定的,因为它存储的是句柄地址,如果对象被移动,句柄池会自动更新,reference不需要更改
    • 使用直接指针的话,减少一次指针指向的时间开销,速度有所提升;

3、String 类和常量池

String s1 = "java" //这里会现在字符串常量池中检查有无“java”,如果没有则创建;
String s2 = new String("java")//这种方式的创建,对象将会在堆内存中;且不检查是否重复的

需要注意的是String的intern方法;如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7 之前(不包含 1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7 以及之后的处理方式是在常量池中记录此字符串的引用,并返回该引用。

String s1 = new String("java");
String s2 = s1.intern();
String s3 = s2.intern();
System.out.println(s1 == s2);//s1 在对内存中,s2在StringPool中 -->false
System.out.println(s2 == s3);//true s2 s3 是StringPool 中的对象 -->true

字符串拼接

//字符串拼接 使用“”的话,是在字符串常量池中拼接;如果使用变量名,则在堆内存中创建对象
String s4 = "hello";
String s5 = "world";

String s6 = "java"+"good"; //字符串常量池
String s7 = s4 + s5; //堆内存中

image-20210612233533409

4、8 种基本类型的包装类和常量池

  • 8种基本类型对应的包装类

    基本类型 包装类
    int Integer
    short Short
    long Long
    byte Byte
    char Character
    float Float
    double Double
    boolean Boolean
  • 实现了常量池技术的6种基本类型对应的包装类

    包装类 常量池范围
    Byte [-128,127]
    Short [-128,127]
    Integer [-128,127]
    Long [-128,127]
    Character [0,127]
    Boolean true or false

参考:

https://snailclimb.gitee.io/javaguide/#/docs/java/jvm/Java内存区域?id=java-内存区域详解

https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html

JVM