Java运行时内存区域—阅读深入理解Java虚拟机整理笔记

一、走进Java (快速浏览,先略过)

1.1 概述

Java不仅仅是一门编程语言,它还是一个由一系列计算机软件和规范组成的技术体系,这个技术体系提供了完整的用于软件开发和跨平台部署的支持环境,并广泛应用于嵌入式系统。

1.2 Java优点

  • 拥有一门结构严谨、面向对象的编程语言。
  • 摆脱了硬件平台的束缚,实现了一次编写,到处运行的理想。
  • 提供了相对安全的内存管理和访问机制,避免了大部分的内存泄露和指针越界问题。
  • 实现了热点代码检测和运行时编译及优化,使得Java应用能随着运行时间的增长而获得更高的性能。
  • 拥有一套完善的应用程序接口,和无数来自商业机构和开源社区的第三方类库来帮助用户实现各种各样的功能

1.3 Java技术体系

  • Java程序设计语言
  • 各种硬件平台上的Java虚拟机实现
  • Class文件格式
  • Java类库API
  • 来自商业机构和开源社区的第三方Java类库

JDK:

是Java程序设计语言、Java虚拟机、Java类库的统称,是用于支持Java程序开发的最小环境。

JRE:

是Java类库API中JavaSE API子集和Java虚拟机这两部分的统称,JRE是支持Java程序运行的标准环境。

JVM:

Java虚拟机,Java能跨平台运行的重要支持。

Java按照技术关注的重点业务来划分,可分为以下4条主要的产品线

Java Card

Java ME

Java SE

Java EE

1.4 Java发展史

1.5 Java虚拟机家族

  • 虚拟机始祖:Sun Classic/Exact VM,世界上第一款商用Java虚拟机
  • 武林盟主:HotSpot VM,Sun/OracleJDK 和OpenJDK中默认的Java虚拟机,也是目前使用最广的Java虚拟机。
  • 小家碧玉:Mobile/Embedded VM,Sun/Oracle研发的专门面对移动和嵌入式市场的Java虚拟机
  • 天下第二:BEAJRockit/ IBM J9 VM
  • 软硬联合: BEA Liquid VM / Azul VM
  • 挑战者:Apache Harmony/ Google Androd Dalvik VM

1.6 展望Java技术的未来

  • 无语言倾向:Graal VM
  • 新一代即时编译器
  • 向Native迈进
  • 灵活的胖子:模块化
  • 语言语法持续增强

二、Java运行时内存区域

概述

对于Java程序员来说,在JVM自动内存管理机制的帮助下,不需要再为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出的问题,不过,也正是因为Java程序员把控制内存的权力交给了JVM,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误、修正问题就会成为一项异常艰难的工作。

在这里插入图片描述

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

JDK 1.8 之前:

在这里插入图片描述

JDK 1.8 :

在这里插入图片描述

2.1 程序计数器

概述:

是一块较小的内存空间,可以看作当前线程所执行的字节码的一个行号指示器,在Java虚拟机的概念模型里面,字节码解释器工作的时候,就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,即字程序计数器里面存的值是当前线程所需要执行的下一条指令的地址。

程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器来完成。

在JVM中多多线程的执行机制是通过线程轮流切换、分配处理器执行时间等方式即时间片轮转的方式来实现的,在任何一个确定的时间,一个处理器都只执行一个线程中的指令,因此,为了线程切换后每个被中断的线程能够恢复到被中断前的执行位置,每个线程都需要一个独立的程序计数器来记录线程信息,各条线程之间的计数器互不影响、独立存储。

作用:

1.是记住下一条jvm指令的执行地址,是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器来完成。

2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

特点:

1.线程私有:每个线程都有对应的程序计数器,互不影响。

2.是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域,即不会有内存溢出的情况。它的生命周期随着线程的创建而创建,随着线程的死亡而死亡。

2.2 Java虚拟机栈

概述

  1. Java虚拟机栈,线程私有,生命周期与线程相同。
  2. 描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧 (Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
  3. 每一个方法被调用直至执行完毕,就对应着一个栈帧在虚拟机栈中的出栈入栈的过程。
    在这里插入图片描述
  1. 局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
    在这里插入图片描述
  1. Java虚拟机栈会出现两种错误: StackOverFlowErrorOutOfMemoryError

    StackOverFlowError:如果线程请求的栈深度大于虚拟机所允许的栈深度,就抛出此异常。

    OutOfMemoryError: 如果Java虚拟机栈可以动态扩展,当扩展的时候无法申请到足够的内存就会报该异常。

在这里插入图片描述

特点

1.线程私有,生命周期与线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

2.存在这TtackOverflowError 和OutOfMemoryError两种异常。

3.局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变。

4.一个栈有多个栈帧。活动栈帧只有一个,对应着当前正在执行的方法。

IDEA演示栈帧

package jvm.t1;

/**
 * 演示栈帧
 */
public class Demo1_1 {
    public static void main(String[] args) {
        method1();
    }
    
    private static void method1(){
        method2(1,2);
    }
    
    private static int method2(int a,int b){
        int c = a+b;
        return c;
    }
}

使用Debug运行上述代码,可以在IDEA中模拟栈帧,如下图

在这里插入图片描述

扩展

  1. 那么方法/函数如何调用?

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

    Java 方法有两种返回方式:

    1.return 语句。

    2.抛出异常。

    不管哪种返回方式都会导致栈帧被弹出。

  2. 垃圾回收是否涉及栈内存?

    不涉及,堆是线程运行需要的空间,随线程的产生而产生,随线程消亡而消亡,是线程私有的内存,不需要垃圾回收机制来回收,垃圾回收机制只回收堆中的内存。

  3. 栈内存越大越好吗?

    并不是越大越好,栈是线程私有的,物理内存大小固定,如果给栈划分内存过大,导致线程数量变少。

    给栈内存指定大小: -Xss size
    -Xss 1m
    -Xss 1024k
    -Xss 1048576

  4. 方法内部的局部变量是否线程安全?

    线程安全,因为栈是线程私有的,当一个线程执行一个方法时,会创建一个独立的栈帧,局部变量存储在栈帧的局部变量表中,每个线程之间的栈帧互不影响。

    • 如果方法内局部变量的作用范围没有逃离方法的作用范围,那么就是线程安全的

    • 如果是局部变量引用了对象,并逃离了方法的作用范围,就需要考虑线程安全。

栈内存溢出演示

1.栈帧过多导致栈内存溢出,比如没有适当结束条件的递归调用就是因为栈帧过多产生栈内存溢出

package jvm.t1;

/**
 * 演示栈内存溢出 java.lang.StackOverflowError
 * -Xss 256k
 */
public class Demo1_2 {

    private static int count;

    public static void main(String[] args) {
        try {
            method1();
        }catch (Throwable e){
            e.printStackTrace();
            System.out.println(count);
        }

    }

    private static void method1(){
        count++;
        method1();
    }
}

分析:

如上代码,由于递归方法没有退出,导致内存溢出。

2.栈帧过大导致栈内存溢出。

2.3 本地方法栈

概述

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

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

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

2.4 Java堆

概述

Java 堆(Java Heap) 是虚拟机所管理的最大的一块内存,被所有的线程共享,在虚拟机启动时创建。此内存的唯一目的就是存放对象实例,Java世界几乎所有的对象实例都是在这里分配内存。

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

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

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)
    在这里插入图片描述

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

在这里插入图片描述

上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。

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

从分配内存的角度看 ,所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。

根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间,但是在逻辑上应该被视为连续的

Java 堆既可以被实现为大小固定的,也是可以扩展的,当下主流的Java虚拟机中的堆都是可以扩展的,扩展命令:

-Xmx和-Xms来设定

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。

  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值

2.5 方法区

概述

方法区(Method Area) 与Java堆一样,是线程共享的内存区域,用于存储存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。Java虚拟机规范把方法区描述为堆的一个逻辑部分, 但是它却有一个别名叫做“非堆”,目的是与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) 呢?

  1. 使用永久代来实现方法区导致了Java应用容易遇到内存溢出的问题(永久代有-XX:MaxPermSize 的上限,即使不设置也有默认大小,而J9 和JRockit只要没有触碰到线程可用内存的上限,例如32位系统中的4GB限制,就不会出现问题),而且有极少数的方法例如String::intern()会因为永久代的原因而导致不同虚拟机下有不同的表现。

  2. 在JDK6的时候,HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK7的HotSpot,已经把原本放在永久代的字符串常量、静态变量等移除,而到了JDK 8,终于完全废弃了永久代的概念,改用了JRockit\J9一样的在本地内存中实现的元空间(Meta-space)来替代,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移动到元空间中。

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

    当元空间溢出时会得到如下错误: `java.lang.OutOfMemoryError: MetaSpace`
    

    可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。 ----- 著作权归Guide哥所有。

  4. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

  5. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

其他

  1. 《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的物理内存和可以选额固定的大小或可扩展外,甚至还可以选择不是先垃圾收集。
  2. 进入方法区的数据并非永久存在的。这个区域的内存回收主要针对的是对常量池的回收和对类型的卸载,条件相当苛刻,但是这部分区域的回收有时又是非常必要的。
  3. 如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

2.6 运行时常量池

运行时常量池(Runtime Constant Pool) 是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池表(Contant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量表池中。

运行时常量池具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也能将新的常量放入常量池当中,被利用的比较多的就是String类的intern()方法。

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

  1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
  2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代
  3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

2.7直接内存

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

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

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。动态扩展时可能会出现OutOFMemoryError异常。

参考

周志明老师的《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值