史上最详细Java内存区域讲解

常见面试题

基本问题

  • 介绍下 Java 内存区域(运行时数据区)
  • Java 对象的建立过程(五步,建议能默写出来而且要知道每一步虚拟机作了什么)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)

拓展问题

  • String类和常量池
  • 8种基本类型的包装类和常量池

1、概述

对于 Java 程序员来讲,在虚拟机自动内存管理机制下,再也不须要像C/C++程序开发程序员这样为内一个 new 操做去写对应的 delete/free 操做,不容易出现内存泄漏和内存溢出问题。正是由于 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,若是不了解虚拟机是怎样使用内存的,那么排查错误将会是一个很是艰巨的任务。java

2、运行时数据区域

Java 虚拟机在执行 Java 程序的过程当中会把它管理的内存划分红若干个不一样的数据区域。JDK. 1.8 和以前的版本略有不一样,下面会介绍到。git

JDK 1.8以前:程序员

JDK 1.8 :github

线程私有的:面试

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:算法

  • 方法区
  • 直接内存(非运行时数据区的一部分)

2.1 程序计数器

程序计数器是一块较小的内存空间,能够看做是当前线程所执行的字节码的行号指示器。字节码解释器工做时经过改变这个计数器的值来选取下一条须要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都须要依赖这个计数器来完。数组

另外,为了线程切换后能恢复到正确的执行位置,每条线程都须要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,咱们称这类内存区域为“线程私有”的内存。缓存

从上面的介绍中咱们知道程序计数器主要有两个做用:安全

  1. 字节码解释器经过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的状况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候可以知道该线程上次运行到哪儿了。

注意:程序计数器是惟一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的建立而建立,随着线程的结束而死亡。数据结构

2.2 Java 虚拟机栈

与程序计数器同样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是经过栈传递的。

Java 内存能够粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是如今说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java虚拟机栈是由一个个栈帧组成,而每一个栈帧中都拥有:局部变量表、操做数栈、动态连接、方法出口信息。)

局部变量表主要存放了编译器可知的各类数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不一样于对象自己,多是一个指向对象起始地址的引用指针,也多是指向一个表明对象的句柄或其余与此对象相关的位置)。

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

  • StackOverFlowError: 若Java虚拟机栈的内存大小不容许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存大小容许动态扩展,且当线程请求栈时内存用完了,没法再动态扩展了,此时抛出OutOfMemoryError异常。

Java 虚拟机栈也是线程私有的,每一个线程都有各自的Java虚拟机栈,并且随着线程的建立而建立,随着线程的死亡而死亡。

扩展:那么方法/函数如何调用?

Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java栈,每个函数调用结束后,都会有一个栈帧被弹出。

Java方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

无论哪一种返回方式都会致使栈帧被弹出。

2.3 本地方法栈

和虚拟机栈所发挥的做用很是类似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会建立一个栈帧,用于存放该本地方法的局部变量表、操做数栈、动态连接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

2.4 堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是全部线程共享的一块内存区域,在虚拟机启动时建立。此内存区域的惟一目的就是存放对象实例,几乎全部的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,所以也被称做GC堆(Garbage Collected Heap).从垃圾回收的角度,因为如今收集器基本都采用分代垃圾收集算法,因此Java堆还能够细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。 上图所示的 eden区、s0区、s1区都属于新生代,tentired 区属于老年代。大部分状况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,若是对象还存活,则会进入 s0 或者 s1,而且对象的年龄还会加 1(Eden区->Survivor 区后对象的初始年龄变为1),当它的年龄增长到必定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,能够经过参数 -XX:MaxTenuringThreshold 来设置。

2.5 方法区

方法区与 Java 堆同样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,可是它却有一个别名叫作 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

方法区也被称为永久代。不少人都会分不清方法区和永久代的关系,为此我也查阅了文献。

方法区和永久代的关系

《Java虚拟机规范》只是规定了有方法区这么个概念和它的做用,并无规定如何去实现它。那么,在不一样的 JVM 上方法区的实现确定是不一样的了。 方法区和永久代的关系很像Java中接口和类的关系,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其余的虚拟机实现并无永久带这一说法。

经常使用参数

JDK 1.8 以前永久代还没被完全移除的时候一般经过下面这些参数来调节方法区大小

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

相对而言,垃圾收集行为在这个区域是比较少出现的,但并不是数据进入方法区后就“永久存在”了。**

JDK 1.8 的时候,方法区(HotSpot的永久代)被完全移除了(JDK1.7就已经开始了),取而代之是元空间,元空间使用的是直接内存。

下面是一些经常使用参数:

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

与永久代很大的不一样就是,若是不指定大小的话,随着更多类的建立,虚拟机会耗尽全部可用的系统内存。

为何要将永久代(PermGen)替换为元空间(MetaSpace)呢?

整个永久代有一个 JVM 自己设置固定大小上线,没法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,而且永远不会获得java.lang.OutOfMemoryError。你可使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小若是未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地从新调整大小。

固然这只是其中一个缘由,还有不少底层的缘由,这里就不提了。

2.6 运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各类字面量和符号引用)

既然运行时常量池时方法区的一部分,天然受到方法区内存的限制,当常量池没法再申请到内存时会抛出 OutOfMemoryError 异常。

JDK1.7及以后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

2.7 直接内存

直接内存并非虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,可是这部份内存也被频繁地使用。并且也可能致使 OutOfMemoryError 异常出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它能够直接使用 Native 函数库直接分配堆外内存,而后经过一个存储在 Java 堆中的 DirectByteBuffer 对象做为这块内存的引用进行操做。这样就能在一些场景中显著提升性能,由于避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会收到 Java 堆的限制,可是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

3、HotSpot 虚拟机对象探秘

经过上面的介绍咱们大概知道了虚拟机的内存状况,下面咱们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

3.1 对象的建立

下图即是 Java 对象的建立过程,我建议最好是能默写出来,而且要掌握每一步在作什么。

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

②分配内存: 在类加载检查经过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后即可肯定,为对象分配空间的任务等同于把一块肯定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式:(补充内容,须要掌握)

选择以上两种方式中的哪种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",仍是"标记-整理"(也称做"标记-压缩"),值得注意的是,复制算法内存也是规整的

内存分配并发问题(补充内容,须要掌握)

在建立对象的时候有一个很重要的问题,就是线程安全,由于在实际开发过程当中,建立对象是很频繁的事情,做为虚拟机来讲,必需要保证线程是安全的,一般来说,虚拟机采用两种方式来保证线程安全:

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

③初始化零值: 内存分配完成后,虚拟机须要将分配到的内存空间都初始化为零值(不包括对象头),这一步操做保证了对象的实例字段在 Java 代码中能够不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

④设置对象头: 初始化零值完成以后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不一样,如是否启用偏向锁等,对象头会有不一样的设置方式。

⑤执行 init 方法: 在上面工做都完成以后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象建立才刚开始,<init> 方法尚未执行,全部的字段都还为零。因此通常来讲,执行 new 指令以后会接着执行 <init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算彻底产生出来。

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局能够分为3块区域:对象头实例数据对齐填充

Hotspot虚拟机的对象头包括两部分信息第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志等等),另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机经过这个指针来肯定这个对象是那个类的实例。

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

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位做用。 由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),所以,当对象实例数据部分没有对齐时,就须要经过对齐填充来补全。

3.3 对象的访问定位

创建对象就是为了使用对象,咱们的Java程序经过栈上的 reference 数据来操做堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄②直接指针两种:

  1. 句柄: 若是使用句柄的话,那么Java堆中将会划分出一块内存来做为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息; 

  2. 直接指针: 若是使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优点。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 自己不须要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

4、重点补充内容

String 类和常量池

1 String 对象的两种建立方式:

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

这两种不一样的建立方法是有差异的,第一种方式是在常量池中拿对象,第二种方式是直接在堆内存空间建立一个新的对象。   记住:只要使用new方法,便须要建立新的对象。

2 String 类型的常量池比较特殊。它的主要使用方法有两种:

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  • 若是不是用双引号声明的 String 对象,可使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的做用是:若是运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;若是没有,则在常量池中建立与此 String 内容相同的字符串,并返回常量池中建立的字符串的引用。
String s1 = new String("计算机");
	      String s2 = s1.intern();
	      String s3 = "计算机";
	      System.out.println(s2);//计算机
	      System.out.println(s1 == s2);//false,由于一个是堆内存中的String对象一个是常量池中的String对象,
	      System.out.println(s3 == s2);//true,由于两个都是常量池中的String对象

3 String 字符串拼接

String str1 = "str";
		  String str2 = "ing";

		  String str3 = "str" + "ing";//常量池中的对象
		  String str4 = str1 + str2; //在堆上建立的新的对象	  
		  String str5 = "string";//常量池中的对象
		  System.out.println(str3 == str4);//false
		  System.out.println(str3 == str5);//true
		  System.out.println(str4 == str5);//false

尽可能避免多个字符串拼接,由于这样会从新建立对象。若是须要改变字符串的话,可使用 StringBuilder 或者 StringBuffer。

String s1 = new String("abc");这句话建立了几个对象?

建立了两个对象。

验证:

String s1 = new String("abc");// 堆内存的地址值
		String s2 = "abc";
		System.out.println(s1 == s2);// 输出false,由于一个是堆内存,一个是常量池的内存,故二者是不一样的。
		System.out.println(s1.equals(s2));// 输出true

结果:

false
true

解释:

先有字符串"abc"放入常量池,而后 new 了一份字符串"abc"放入Java堆(字符串常量"abc"在编译期就已经肯定放入常量池,而 Java 堆上的"abc"是在运行期初始化阶段才肯定),而后 Java 栈的 str1 指向Java堆上的"abc"。

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

  • Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认建立了数值[-128,127]的相应类型的缓存数据,可是超出此范围仍然会去建立新的对象。
  • 两种浮点数类型的包装类 Float,Double 并无实现常量池技术。
Integer i1 = 33;
		Integer i2 = 33;
		System.out.println(i1 == i2);// 输出true
		Integer i11 = 333;
		Integer i22 = 333;
		System.out.println(i11 == i22);// 输出false
		Double i3 = 1.2;
		Double i4 = 1.2;
		System.out.println(i3 == i4);// 输出false

Integer 缓存源代码:

/**
*此方法将始终缓存-128到127(包括端点)范围内的值,并能够缓存此范围以外的其余值。
*/
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

应用场景:

  1. Integer i1=40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  2. Integer i1 = new Integer(40);这种状况下会建立新的对象。
Integer i1 = 40;
  Integer i2 = new Integer(40);
  System.out.println(i1==i2);//输出false

Integer比较更丰富的一个例子:

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));
  System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
  System.out.println("i1=i4   " + (i1 == i4));
  System.out.println("i4=i5   " + (i4 == i5));
  System.out.println("i4=i5+i6   " + (i4 == i5 + i6));   
  System.out.println("40=i5+i6   " + (40 == i5 + i6));

结果:

i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
40=i5+i6   true

解释:

语句i4 == i5 + i6,由于+这个操做符不适用于Integer对象,首先i5和i6进行自动拆箱操做,进行数值相加,即i4 == 40。而后Integer对象没法与数值进行直接比较,因此i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。

相关文章
相关标签/搜索