【JVM JMM】Java虚拟机 Java内存学习入门篇

Java虚拟机 JVM学习

一、什么是JVM

1.定义

Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)

JVM 是 Java 的核心和基础,在 Java 编译器和 os 平台之间的虚拟处理器。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,可以在上面执行 Java 的字节码程序。

Java 编译器只需面向 JVM,生成 JVM 能理解的代码或字节码文件。Java 源文件经编译器,编译成字节码程序,通过 JVM 将每一条指令翻译成不同平台机器码,通过特定平台运行。

2.JVM JRE JDK区别

在这里插入图片描述

3.JVM物理结构

在这里插入图片描述

二、Java代码编译和执行过程

1.编译和执行过程

Java 代码编译是由 Java 源码编译器来完成,如下流程图。

image-20220225104536555

  • 词法分析器:输入源程序,按照构词规则分解成一系列单词符号。
  • Token:构成源代码的最小单位,从输入字符流中生成标记的过程叫作标记化( tokenization ),在这个过程中,词法分析器还会对token进行分类。
  • 字节码:是Java虚拟机执行的一种指令格式。大多数操作码都是一个字节长,而有些操作需要参数,导致了有一些多字节的操作码。

Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示

在这里插入图片描述

2.Java源码编译机制

Java 源码编译由以下三个过程组成:

  • 分析和输入到符号表
  • 注解处理
  • 语义分析和生成 class 文件

image-20220225164505921

最后生成的 class 文件由以下部分组成:

  • 结构信息。包括 class 文件格式版本号及各部分的数量与大小的信息。
  • 元数据。对应于 Java 源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池。
  • 方法信息。对应 Java 源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。

三、类加载机制

JVM 的类加载是通过 ClassLoader 及其子类来完成的

在这里插入图片描述

  1. class file(在下图中就是Car.class文件)存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。

  2. class file加载到JVM中,被称为DNA元数据模板(在下图中就是内存中的Car Class),放在方法区。

  3. 在.class文件–>JVM–>最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色。

    image-20220225223456449

四、字节码执行引擎

字节码生成是 javac 编译过程的最后一个阶段。字节码生成阶段不仅仅是把前面各个步骤所生成的信息转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。 实例构造器()方法和类构造器()方法就是在这个阶段添加到语法树之中的(这里的实例构造器并不是指默认的构造函数,而是指我们自己重载的构造函数,如果用户代码中没有提供任何构造函数,那编译器会自动添加一个没有参数、访问权限与当前类一致的默认构造函数,这个工作在填充符号表阶段就已经完成了)。

外观上:所有 JVM 的执行引擎都是一致的。输入的是字节码文件,处理的是字节码解析的等效过程,输出的是执行结果

image-20220225230520423

实现上,执行引擎有多种执行 Java 代码的选择

  • 解释器执行:一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。

  • 即时(Just-In-Time)编译器执行:执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即使编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以通过本地代码去执行它。执行本地代码比一条一条解释执行的速度快很多。编译后的代码可以执行得很快,因为本地代码是保存在缓存里的。

  • 两者兼备,甚至还会包含几个不同级别的编译器执行引擎

执行引擎以指令为单位读取Java字节码。就像CPU一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。

五、JVM内存模型

1.Java内存模型(JMM)

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

在这里插入图片描述

Java 虚拟机在执行 Java 程序的过程中会把他所管理的内存划分为若干个不同的数据区域。Java 虚拟机规范将 JVM 所管理的内存分为以下几个运行时数据区:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。

  • 线程独有:独立包括程序计数器、栈、本地方法栈
  • 线程间共享:堆、堆外内存(永久代或元空间、代码缓存)

image-20220225224430631

2.JVM内存结构

Java 虚拟机的内存空间分为 5 个部分:

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

image-20220225124854174

3.JVM中的的堆和栈

JVM是基于堆栈的虚拟机,JVM为每个新创建的线程都分配一个堆栈。也就是说,对于一个Java程序来说,它的运行就是通过对堆栈的操作来完成的。

栈是运行时的单位,而堆是存储的单位。即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放,放哪里。

堆栈以帧为单位保存线程的状态。JVM对堆栈只进行两种操作:以帧为单位的压栈和出栈操作。 我们知道,某个线程正在执行的方法称为此线程的当前方法。当前方法使用的帧称为当前帧。当线程激活一个Java方法,JVM就会在线程的 Java堆栈里新压入一个帧。这个帧自然成为了当前帧。在此方法执行期间,这个帧将用来保存参数、局部变量、中间计算过程和其他数据。

从Java的这种分配机制来看,堆栈又可以这样理解:堆栈是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有先进后出的特性。

**每一个Java应用都唯一对应一个JVM实例,每一个实例唯一对应一个堆。**应用程序在运行中所创建的所有类实例或数组都放在这个堆中,并由应用所有的线程共享。

跟C/C++不同,Java中分配堆内存是自动初始化的。Java中所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配,也就是说在建立一个对象时从两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。

4.JVM生命周期

虚拟机的启动

Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。

虚拟机的执行
  1. 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
  2. 程序开始执行时他才运行,程序结束时他就停止
  3. 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程
虚拟机的退出

有如下的几种情况:

  1. 程序正常执行结束
  2. 程序在执行过程中遇到了异常或错误而异常终止
  3. 由于操作系统用现错误而导致Java虚拟机进程终止
  4. 某线程调用Runtime类或System类的exit()方法,或Runtime类的halt()方法,并且Java安全管理器也允许这次exit()或halt()操作。
  5. 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。

5.内存溢出

各内存区域内存溢出的简单测试方法。

image-20220225225243174

在多线程情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。操作系统为每个进程分配的内存是有限制的,虚拟机提供了参数来控制 Java 堆和方法区这两部分内存的最大值,忽略掉程序计数器消耗的内存(很小),以及进程本身消耗的内存,剩下的内存便给了虚拟机栈和本地方法栈,每个线程分配到的栈容量越大,可以建立的线程数量自然就越少。因此,如果是建立过多的线程导致的内存溢出,在不能减少线程数的情况下,就只能通过减少最大堆和每个线程的栈容量来换取更多的线程。

Java 堆内也可能发生内存泄露(Memory Leak),简要说明内存泄露和内存溢出的区别:

内存泄露是指分配出去的内存没有被回收回来,由于失去了对该内存区域的控制,因而造成了资源的浪费。Java 中一般不会产生内存泄露,因为有垃圾回收器自动回收垃圾,但这也不绝对,当我们 new 了对象,并保存了其引用,但是后面一直没用它,而垃圾回收器又不会去回收它,这边会造成内存泄露,

内存溢出是指程序所需要的内存超出了系统所能分配的内存(包括动态扩展)的上限。

六、GC(Garbage Collection)垃圾回收

1.什么是垃圾

  1. 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
  2. 外文:An object is considered garbage when it can no longer be reached from any pointer in the running program.
  3. 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。

2.为什么需要GC

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

  1. 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
  2. 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW(Stop the World,在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起,除了垃圾收集帮助器之外)的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

3.对象引用

  • 强引用:如“Object obj = new Object()”,这类引用是 Java 程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用:它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2 之后提供了 SoftReference 类来实现软引用。
  • 弱引用:它也是用来描述非需对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后,提供了 WeakReference 类来实现弱引用。
  • 虚引用:最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2 之后提供了 PhantomReference 类来实现虚引用。

4.垃圾对象的判定

Java 堆中存放着几乎所有的对象实例,垃圾收集器对堆中的对象进行回收前,要先确定这些对象是否还有用,判定对象是否为垃圾对象有如下算法:

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1,当引用失效时,计数器值就减1,任何时刻计数器都为 0 的对象就是不可能再被使用的。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的选择,当 Java 语言并没有选择这种算法来进行垃圾回收,主要原因是它很难解决对象之间的相互循环引用问题。

根搜索算法

Java 和 C# 中都是采用根搜索算法来判定对象是否存活的。这种算法的基本思路是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。在 Java 语言里,可作为 GC Roots 的兑现包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中 JNI(Native 方法)的引用对象。

在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法,或 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果该对象被判定为有必要执行 finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的 finalize()方法最多只会被系统自动调用一次),稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果要在 finalize()方法中成功拯救自己,只要在 finalize()方法中让该对象重引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

5.垃圾收集算法

判定除了垃圾对象之后,便可以进行垃圾回收了。下面介绍一些垃圾收集算法,由于垃圾收集算法的实现涉及大量的程序细节,因此这里主要是阐明各算法的实现思想,而不去细论算法的具体实现。

标记—清除算法

标记—清除算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程。标记—清除算法的执行情况如下图所示:

image-20220226105615092

标记—整理算法

复制算法比较适合于新生代,在老年代中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。标记—整理算法的回收情况如下所示:

image-20220226105739892

分代收集

当前商业虚拟机的垃圾收集 都采用分代收集,它根据对象的存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集,而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。

垃圾收集器

垃圾收集器是内存回收算法的具体实现,Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大的差别。Sun HotSpot 虚拟机 1.6 版包含了如下收集器:Serial、ParNew、Parallel Scavenge、CMS、Serial Old、Parallel Old。这些收集器以不同的组合形式配合工作来完成不同分代区的垃圾收集工作。

垃圾回收分析

在用代码分析之前,我们对内存的分配策略明确以下三点:

  • 对象优先在 Eden 分配。
  • 大对象直接进入老年代。
  • 长期存活的对象将进入老年代。

对垃圾回收策略说明以下两点:

  • 新生代 GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,因此Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢 10 倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC时,会顺便清理掉 Direct Memory 中的废弃对象。

七、JVM调优

Java 虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序(尤其服务器端)的性能和稳定性有着非常重要的影响。性能调优需要具体情况具体分析,而且实际分析时可能需要考虑的方面很多,这里仅就一些简单常用的情况作简要介绍。

  • 我们可以通过给 Java 虚拟机分配超大堆(前提是物理机的内存足够大)来提升服务器的响应速度,但分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,因为一次 Full GC 的时间造成比较长时间的停顿。控制 Full GC 频率的关键是保证应用中绝大多数对象的生存周期不应太长,尤其不能产生批量的、生命周期长的大对象,这样才能保证老年代的稳定。
  • Direct Memory 在堆内存外分配,而且二者均受限于物理机内存,且成负相关关系,因此分配超大堆时,如果用到了 NIO 机制分配使用了很多的 Direct Memory,则有可能导致 Direct Memory 的 OutOfMemoryError 异常,这时可以通过 -XX:MaxDirectMemorySize 参数调整 Direct Memory 的大小。
  • 除了 Java 堆和永久代以及直接内存外,还要注意下面这些区域也会占用较多的内存,这些内存的总和会受到操作系统进程最大内存的限制:

1、线程堆栈:可通过 -Xss 调整大小,内存不足时抛出 StackOverflowError(纵向无法分配,即无法分配新的栈帧)或 OutOfMemoryError(横向无法分配,即无法建立新的线程)。

2、Socket 缓冲区:每个 Socket 连接都有 Receive 和 Send 两个缓冲区,分别占用大约 37KB 和 25KB 的内存。如果无法分配,可能会抛出 IOException:Too many open files 异常。关于 Socket 缓冲区的详细介绍参见我的 Java 网络编程系列中深入剖析 Socket 的几篇文章。

3、JNI 代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中。

4、虚拟机和 GC:虚拟机和 GC 的代码执行也要消耗一定的内存。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值