jvm之java内存区域详解篇guide哥yyds

jvm

一、java内存区域详解

1.运行时数据区域

jvm之java内存区域详解篇guide哥yyds_第1张图片
jvm之java内存区域详解篇guide哥yyds_第2张图片

线程私有的:

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

线程共享的:

  • 方法区
  • 直接内存(非程序运行时数据区的一部分)
1.1什么是程序计数器

程序计数器是很小的内存空间,可以看作是前线程字节码执行的行号指示器。

作用:

1.字节码解释器通过程序计数器来读取指令,从而实现代码的流程控制,比如:顺序执行、选择、循环、异常处理等。

2.在多线程的情况下,程序计数器记录当前线程的位置,为后面线程切换恢复到正确的位置提供保证。各线程之间的程序计数器互不影响,相互独立,称这类内存区域为“线程私有”的内存。

==注意:==程序计数器是唯一一个不会出现**OutOfMemoryError**的内存区域,生命周期随着线程的创建而创建,随着线程的结束而结束。

1.2什么是java虚拟机栈

与程序计数器相同,java虚拟机栈也是线程私有的,生命周期随着线程的创建而创建,随着线程的结束而结束。描述的是java方法执行时的内存模型,每次方法调用的数据都是通过栈完成的。

java内存可以粗糙的分为堆内存和栈内存,其中栈指java虚拟机栈(实际上java虚拟机栈是由一个个栈帧组成的,而每个栈帧包括:局部变量表,操作数栈,动态链接,方法出口信息

局部变量表

局部变量表主要存放编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)

、对象类型.

Java 虚拟机栈会出现两种错误:StackOverFlowErrorOutOfMemoryError

  • **StackOverFlowError:**若java虚拟机栈的内存不允许动态扩展,那么线程请求栈的深度超过java虚拟机栈的最大深度,就会抛出StackOverFlowError异常
  • **OutOfMemoryError:**java虚拟机栈的内存大小可以动态扩展,如果java虚拟机在扩展栈时无法申请到足够的内存空间,就会抛出OutOfMemoryError

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

java虚拟机栈中保存的主要内容是栈帧,每次调用函数时都会有一个对应的栈帧被压入java虚拟机栈中,每一个函数结束后,都会有一个对应的栈帧被弹出栈

java返回的方式:

  • return语句
  • 抛出异常

两种方式都会导致栈帧被弹出。

1.3什么是本地方法栈

本地方法栈和java虚拟机栈类似,**区别是:java虚拟机栈是为执行java方法(字节码)服务的,而本地方法栈是为虚拟机用到的native方法服务的。在HotSpot虚拟机中,java虚拟机栈和本地方法栈合二为一。

本地方法被执行的时候,在本地方法栈中也会创建一个栈桢,用于存放局部变量表,操作数栈,动态链接,出口信息。

方法执行完毕后,相应的栈帧也会出栈并且释放内存空间,也会出现StackOverFlowErrorOutOfMemoryError 两种错误。

1.4什么是堆

java虚拟机中内存管理的最大的一块区域,java堆是线程共享的内存区域,在虚拟机创建时,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存

Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存

java堆是垃圾收集器管理的主要区域,因此成为GC 堆(Garbage Collected Heap),从垃圾回收的角度,现在收集器基本都采用分代垃圾收集算法,所有java堆还可以细分为:新生代和老年代,再细致一点:Eden空间,From Survivor、To Survivor空间等。进一步的划分目的是更好的回收内存,或更快的分配内存。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

jvm之java内存区域详解篇guide哥yyds_第3张图片

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 元空间(Metaspace)

jvm之java内存区域详解篇guide哥yyds_第4张图片

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

堆最容易出现的就是OutOfMemoryError 错误:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见:Default Java 8 max heap size (opens new window))
1.5什么是方法区

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

方法区也被称为永久代。

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

整个永久代有一个jvm设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

  1. 当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
1.6什么是运行时常量池

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

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

1.7什么是直接内存

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

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

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

2.HotSpot虚拟机对象探秘

2.1对象的创建过程

Java对象的创建过程
jvm之java内存区域详解篇guide哥yyds_第5张图片

1.类加载检查

当虚拟机遇到一条new指令时,首先将去检查new指令的参数是否能在常量池中定位到这个类的引用,并且检查这个类是否已经加载过,如果没有就必须先执行相应的类加载。

2.分配内存

在类检查通过后,虚拟机将会为新生对象分配内存空间,对象所需的内存大小在类加载完成便可确定。

内存分配方式有两种:

  1. 指针碰撞
  2. 空闲列表

选择哪种分配方式,取决于java堆内存是否规范,java堆内存是否规范,取决于GC收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的

内存分配并发问题

在创建对象时,线程安全问题需要考虑,虚拟机采用两种方式来保证线程安全:

  1. CAS+失败重试:

    CAS是乐观锁的一种实现方式。所谓乐观锁就是每次不加锁而是假设没有冲突去完成某项操作,ru’g因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

  2. TLAB:

    为每一个线程预先在Eden区分配一块儿内存,jvm在给线程中的对象分配内存时,首先在TLAB中分配,当对象大于TLAB剩余内存或者TLAB内存耗尽,在采用上述的CAS进行内存分配。

3.初始化零值

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

4.设置对象头

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

5.执行init方法

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

2.2对象的内存布局

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

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

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

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

2.3对象的访问定位

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

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
    jvm之java内存区域详解篇guide哥yyds_第6张图片

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

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

3.字符串的补充内容

3.1字符串常量池常见问题

我们先来看一个非常常见的面试题:String 类型的变量和常量做“+”运算时发生了什么?

先来看字符串不加 final 关键字拼接的情况(JDK1.8):

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

==注意 :==比较 String 字符串的值是否相等,可以使用 equals() 方法。 String 中的 equals 方法是被重写过的。 Objectequals 方法是比较的对象的内存地址,而 Stringequals 方法比较的是字符串的值是否相等。如果你使用 == 比较两个字符串是否相等的话,IDEA 还是提示你使用 equals() 方法替换。

对于基本数据类型来说,== 比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。

字符串常量池 是 JVM 为了提升性能和减少内存消耗针为字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

常量折叠

常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。

对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";

并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  1. 基本数据类型(byte、boolean、short、char、int、float、long、double)以及字符串常量
  2. final 修饰的基本数据类型和字符串变量
  3. 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。

final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 常量池中的对象
System.out.println(c == d);// true

final 关键字修改之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就想到于访问常量。

如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。

示例代码如下(str2 在运行时才能确定其值):

final String str1 = "str";
final String str2 = getStr();
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 在堆上创建的新的对象
System.out.println(c == d);// false
public static String getStr() {
      return "ing";
}

我们再来看一个类似的问题!

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

上面的代码运行之后会输出什么呢?

答案是:

false
false

这是为什么呢?

我们先来看下面这种创建字符串对象的方式:

// 从字符串常量池中拿对象
String str1 = "abcd";

这种情况下,jvm 会先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";

因此,str1 指向的是字符串常量池的对象。

我们再来看下面这种创建字符串对象的方式:

// 直接在堆内存空间创建一个新的对象。
String str2 = new String("abcd");
String str3 = new String("abcd");

只要使用 new 的方式创建对象,便需要创建新的对象

使用 new 的方式创建对象的方式如下,可以简单概括为 3 步:

  1. 在堆中创建一个字符串对象
  2. 检查字符串常量池中是否有和 new 的字符串值相等的字符串常量
  3. 如果没有的话需要在字符串常量池中也创建一个值相等的字符串常量,如果有的话,就直接返回堆中的字符串实例对象地址。

因此,str2str3 都是在堆中新创建的对象。

字符串常量池比较特殊,它的主要使用方法有两种:

  1. 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  2. 如果不是用双引号声明的 String 对象,使用 String 提供的 intern() 方法也有同样的效果。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7 之前(不包含 1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7 以及之后,字符串常量池被从方法区拿到了堆中,jvm 不会在常量池中创建该对象,而是将堆中这个对象的引用直接放到常量池中,减少不必要的内存开销。

示例代码如下(JDK 1.8) :

String s1 = "Javatpoint";
String s2 = s1.intern();
String s3 = new String("Javatpoint");
String s4 = s3.intern();
System.out.println(s1==s2); // True
System.out.println(s1==s3); // False
System.out.println(s1==s4); // True
System.out.println(s2==s3); // False
System.out.println(s2==s4); // True
System.out.println(s3==s4); // False

总结

  1. 对于基本数据类型来说,==比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。
  2. 在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
  3. 一般来说,我们要尽量避免通过 new 的方式创建字符串。使用双引号声明的 String 对象( String s1 = "java" )更利于让编译器有机会优化我们的代码,同时也更易于阅读。
  4. final 关键字修改之后的 String 会被编译器当做常量来处理,编译器程序编译期就可以确定它的值,其效果就想到于访问常量
3.2 String s1 = new String(“abc”);这句话创建了几个字符串对象?

会创建 1 或 2 个字符串:

  • 如果字符串常量池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。
  • 如果字符串常量池中没有字符串常量“abc”,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
3.3 8种基本类型的包装类和常量池

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True Or False

两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。

会创建 1 或 2 个字符串:

  • 如果字符串常量池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。
  • 如果字符串常量池中没有字符串常量“abc”,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
3.3 8种基本类型的包装类和常量池

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True Or False

两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。

记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较

文章内容来源于对guide哥的java学习总结,兄弟们可以去guide哥的仓库地址 去看看 https://gitee.com/SnailClimb/JavaGuide 只能说guide哥yyds!!!!!!!

你可能感兴趣的:(java,开发语言,后端,jvm)