Java虚拟机内存详解

概述

Java虚拟机会自动管理内存,不容易出现内存泄漏和内存溢出问题。Java虚拟机会在执行过程中将管理的内存分为若干个不同的数据区域。

运行时数据区域

在jdk1.8之前的版本与1.8版本略有不同,在jdk1.8之前:

jdk1.8:

以上图片来源:https://github.com/LikFre/JavaGuide

 线程共享区域:

    1.堆

    2.方法区

    3.直接内存(非运行时数据区)

线程私有区域:

    1.虚拟机栈

    2.本地方法栈

    3.程序计数器

线程私有区域:

1.虚拟机栈

  1.它的生命周期随着线程的创建而创建,随着线程的结束而死亡;

  2.描述的是java方法执行的内存模型,java虚拟机栈是由一个个栈帧组成,每个栈帧都有局部变量表、动态链接、操作数栈、方法出口信息。

  3.局部变量表主要存放可知的各种数据类型(8种基本数据类型变量,对象引用变量);

  4.Java虚拟机栈是线程私有的,每个线程都有自己的虚拟机栈;

  5.java虚拟机栈中主要存放的是一个个栈帧,每调用一次方法都会有对应的栈帧压入虚拟机栈,每一个方法执行结束后,都会有一个栈帧弹出。

    Java方法有两种返回方式:1、return;

                 2、抛出异常;

    这两种方式都会导致栈帧弹出;

  java虚拟机栈会出现两种异常:StackOverFlowError和OutOfMemoryError :

    1.StackOverFlowError:如果java虚拟机栈的内存大小不允许动态扩展,当虚拟机栈请求的内存超过栈的最大内存时就会出现StackOverFlowError异常;

    2.OutOfMemoryError:如果java虚拟机栈的内存大小允许动态扩展,当线程请求栈的内存已用完,且无法再动态扩展时,抛出OutOfMemoryError异常;

2.本地方法栈

  1.与java虚拟机栈的生命周期相同,都是线程私有,随着线程的创建而创建,线程的结束而死亡。

  2.描述的是Native方法执行的内存模型,也是由栈帧组成,每个栈帧用于存放本地方法的局部变量表,操作数栈、动态链接、方法出口信息;

  3.也会出现两种异常:StackOverFlowError和OutOfMemoryError

  在HotSpot虚拟机中,虚拟机栈与本地方法栈合二为一;

3.程序计数器

  1.程序计数器是一块较小的内存空间,字节码解释器通过改变计数器的值来选取下一条字节码指令。分支、跳转、循环、异常处理、线程恢复都需要依赖程序计数器;

  2.程序计数器也是线程私有的,每个线程都有自己的程序计数器;

  3.程序计数器主要有两个作用:

    1.字节码解释器通过改变程序计数器的值来顺序的执行字节码指令;如:顺序执行、循环、跳转等;

    2.在多线程环境下,程序计数器用于记录当前线程执行的位置,当CPU切换再次执行当前线程时从程序计数器记录的位置继续执行;

  注意:程序计数器是唯一一个不会出现OutOfMemoryError异常的内存区域,它的生命周期随着线程的创建而创建,线程的结束而死亡;

线程共享区域:

1.堆

  1.java虚拟机管理的最大的一块内存区域,是所有线程共享的内存区域,随着虚拟机的启动而创建,主要用于存放对象实例,几乎所有的对象实例和数组都在堆中存储。

  2.堆也是垃圾收集器主要管理的区域,因此也被称为GC堆(Garbage Collected Heap);从垃圾回收的角度:由于现在的收集器都采用分代收集算法,堆又被分为:新生代和老年代,进一步分为Eden空间、From  Survivor、ToSurvivor。进一步划分目的是为了更好的回收内存或者更好的分配内存。

  

  3.上图中,Eden、S0、S1是新生代,TenTired是老年代。大部分情况,一个对象实例创建后存储在Eden区域,经历过一次垃圾回收之后,如果对象还存活,对象的年龄加+1(由Eden区进入到Survivor区),进入S0或者S1,当对象的年龄达到一定的阈值(默认是15),这个对象才会进入老年代区域,对象的阈值可以通过参数(-XX:MaxTenuringThreshold)来设置;

2.方法区

  1.与java堆一样,是线程共享的内存区域,主要用于存储加载过的类信息、被final修饰的常量、静态变量以及即时编译器编译的代码,虽然java虚拟机规范把方法区列为堆的一个逻辑部分,但是它却有一个别名NON-Heap(非堆),可能就是为了和堆区分开。

  2.方法区也被称为永久代,方法区是java虚拟机中的一种规范,并没有去实现它,在不同的JVM方法区的实现也不同,永久代是HotSpot的概念,在HotSpot虚拟机中永久代是对方法区的一种实现,其他的虚拟机并没有永久代这一说法

  3.在jdk1.8之前,方法区还没有被移除,通过两个参数可以设置永久代的大小:

    -XX:PermSize=N //方法区 (永久代) 初始大小

    -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常

  垃圾收集行为在这个区域很少出现,但并不是数据进入方法区就一直存在

  4.jdk1.8版本,方法区(永久代)被移除,取而代之的时元空间,使用的是直接内存,可以通过以下两个参数设置

     -XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)

     -XX:MaxMetaspaceSize=//设置 Metaspace 的最大大小

        与方法区最大的的不同就是,如果不指定大小,随着更多的类创建,虚拟机可能会耗尽所有可用的系统内存  

    5.为什么要用元空间替换永久代?

  整个永久代有JVM设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,只受本机可用内存大小的限制,如果不指定最大元空间大小,-XX:MaxMetaspaceSize默认大小是unlimited,只受到本机可用内存大小限制,如果不指定初始大小,JVM会动态的分配应用程序所需要的大小。这只是其中一个原因,有兴趣的可以去查阅资料。

3.运行时常量池

  1.是方法区的一部分,主要用来存放编译器生成的各种字面量和引用。字面量(文本字符串、基本数据类型的值、声明为final的常量值、其他)

  2.常量池的大小也受到方法区大小的限制,超过常量池的大小会报OutOfMemoryError异常。

  3.jdk1.7及以后JVM将运行时常量池从方法区移出,在堆中开辟了一块区域用来存放运行时常量池。

直接内存:

  不属于运行时数据区,也不是虚拟机规范中定义的内存区域,但是被频繁的使用,也有可能引起OutOfMemoryError异常。

  jdk1.4中新加入的NIO,使用一种基于通道(Channel)和缓存(Buffer)的方式,直接使用Native方法分配堆外内存,通过在java堆中一个DirectByteBuffer对象作为这块内存的引用来操作,避免了java堆与Native堆之间来回复制数据,提高了性能。

HotSpot虚拟机:

1.对象的创建:

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

Step2:分配内存,类加载检查通过后,虚拟机为新生的对象分配内存,在类加载完成后已经确定了对象的大小,分配内存实际就是在堆中分配一块确定大小的内存,分配内存有两种方式:指针碰撞空闲列表

    分配内存的方式取决于java堆内存是否规整,java堆内存是否规整取决于GC收集算法是“标记-清理”还是“标记-整理”;

    1.指针碰撞:堆内存规整,使用过的内存全部整合到一边,没有使用的内存在另一边,中间有一个分界值指针,只需要将指针往没有使用的内存方向移动对象内存大小即可。GC收集器:Serial、ParNew;

    2.空闲列表:堆内存不规整,虚拟机会维护一个列表,列表中记录未使用的内存大小,选择一块足够的内存来为对象分配,更新列表的记录。GC收集器:CMS

     分配内存的并发问题:对象的创建是很频繁的,虚拟机要保证线程的安全性,采用两种方式:

    1.CAS+失败重试:CAS是乐观锁的一种方式,每次认为不会有冲突的去执行某项操作,遇到冲突一直重试,知道成功为止。虚拟机使用CAS加失败重试的方式来保证更新操作的原子性。

    2.TLAB:为每一个线程预先在Eden区分配一块内存,线程为对象分配内存时,首先在TLAB中分配,TLAB中内存不够时或者已经用尽时,再使用CAS上述方式。

Step3:初始化零值,内存分配完成后,需要将分配到的内存空间初始化为零值,这一步保证了对象在不初始化的情况下,对象的实例字段在java代码中可以不赋初始值就使用。

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

Step5:执行init方法,在new出对象之后,把对象按照程序员的意愿进行初始化,执行init方法。

2.对象在内存中的布局:

对象在内存中的布局可分为三个区域:对象头、实例数据和对齐填充

1.对象头分为两部分:一部分存储自身的运行时数据(哈希码、GC分代年龄等),另一部分类型指针,通过指针来确定这个对象是哪个类的实例

2.实例数据:是真正存储对象的有效信息,对象的各种类型的字段内容。

3.对齐填充:不是必然存在的只是起到占位作用。HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,前两部分不足8字节的整数倍时,使用对齐填充补足。

3.对象的访问定位:

创建对象就是为了使用对象,通过在栈上存储的引用类型变量来操作堆上的具体类型变量,对象的访问方式由虚拟机决定,目前主流的有两种:①使用句柄 ②直接指针

1.使用句柄:java堆中会有一块内存作为句柄池,栈中的引用类型变量中保存的是句柄池的句柄,句柄池中有对象的实例数据和对象类型信息的地址,也就是引用变量访问句柄池,句柄池再访问对象。

2.直接指针:栈中的引用类型变量中直接保存的是对象的地址,可以直接访问。

使用句柄访问的好处是,变量中保存的是稳定的句柄池的地址,在对象被移动时改变句柄池中的地址即可,变量保存的地址不需要改变。

使用直接指针的好处是:速度快,节省了一次指针定位的时间。

内容补充:

String类与常量池

String对象的两种创建方式:

String str1 = "Hello" ;
String str2 = new String("Hello");
String str3 = new String("Hello");
System.out.println(str1 == str2);//false
System.out.println(str2 == str3);//false

 

 str1.先检查常量池中有没有“Hello”,没有就在常量池中创建,然后str1指向常量池中“Hello”的地址,常量池中已经有的话,str1直接指向常量池中“Hello”的地址。

str2,在堆中重新创建一个新的对象

第一种创建方式(str1):在常量池中拿对象,

第二种创建方式(str2、str3):直接在堆内存中创建新的对象

String类型的常量池有两种使用方式

 

        String str1 = new String("Hello" );
        String str2 = str1.intern();
        String str3 = "Hello";
        System.out.println(str1 == str2);//false
        System.out.println(str2 == str3);//true

 

上述代码中 str2和str3指向的都是字符串常量池中的“Hello”

str1:会在堆内存中创建String对象

str2:使用String提供的intern方法,intern()方法是一个本地方法,它的作用是:如果当前字符串对象的字符串已经在常量池中,那么直接返回常量池中该字符串的引用,如果当前字符串对象的字符串内容不在常量池中,那么在常量池中创建一个字符串,返回该字符串的引用。

str3:也是指向常量池中的字符串,所以str2与str3相等

字符串拼接:

        String str1 = "hel";
        String str2 = "lo";
        String str3 = "hel"+"lo";
        String str4 = str1 + str2;
        String str5 = "hello";
        System.out.println(str3 == str4);//false
        System.out.println(str4 == str5);//false
        System.out.println(str3 == str5);//true 

 

str4是在堆上新建的对象,str3与str5都是常量池中的“hello”

8种基本类型的包装类与常量池

java基本类型的包装类中有六种实现的常量池技术:Byte、Short、Integer、Long、Character、Boolean,前五种默认使用[-128,127]的缓存数据,超出这个范围才会创建对象。Double和Float没有实现常量池技术。

Integer  i1 = 44; 默认使用Integer.valueOf(44),从而使用常量池中的数据,只有超过【-128,127】才会在对中创建对象。  

Integer i2 = new Integer(44);这种直接在堆中创建对象,

 Integer i1 = 40;
  Integer i2 = 40;
  Integer i3 = 0;
  Integer i4 = new Integer(40);
  Integer i5 = new Integer(40);
  Integer i6 = new Integer(0);
  
  System.out.println("i1=i2   " + (i1 == i2));//true
  System.out.println("i1=i2+i3   " + (i1 == i2 + i3));//true
  System.out.println("i1=i4   " + (i1 == i4));//false
  System.out.println("i4=i5   " + (i4 == i5));//false
  System.out.println("i4=i5+i6   " + (i4 == i5 + i6));//true   
  System.out.println("40=i5+i6   " + (40 == i5 + i6));//true

 i5+i6这个操作符“+”不适用于Integer对象,所以i5与i6自动拆箱,加之后就变为:i4=40,又因为i4是Integer对象,无法与int对象比较,i4自动拆箱为int值为40,所以相等

 原文地址:https://github.com/LikFre/JavaGuide/blob/master/docs/java/jvm/Java%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F.md

posted @ 2019-06-21 22:57  LikFre  阅读(3243)  评论(0编辑  收藏  举报