内存与垃圾回收篇
字节码与类的加载篇
性能监控与调优篇
大厂面试篇
文章目录
- JVM 跨语言的平台
- 虚拟机与Java虚拟机
- Java 代码的执行流程
- JVM的架构模型
- JVM的生命周期
- 内存结构概述
- 概述类的加载器和类加载过程
- 类加载器
- 虚拟机的分类
- 双亲委派机制
- 沙箱机制
- 类的主动使用与被动使用
- 运行时数据区的内部结构
- PC 寄存器概述
- PC 寄存器的使用用例
- PC 寄存器中的两个面试问题
- 虚拟机栈
- 虚拟机栈的常见异常和如何设置栈大小
- 栈的存储结构和运行原理
- 栈帧的内部结构
- 局部变量表
- 变量槽 slot 的理解与演示
- 静态变量与局部变量的对比及小结
- 操作数栈
- 涉及操作数栈的字节码指令分析
- 栈顶缓存技术
- 动态链接
- 方法的绑定机制:静态绑定与动态绑定(方法的调用)
- 四种方法调用指令区分非虚方法与虚方法
- invokedynamic 指令的使用
- 方法重写的本质与虚方法表的使用
- 方法返回地址说明
- 一些附加信息
- 虚拟机栈的5道面试题
- 本地方法接口的理解
- 本地方法栈的理解
- 堆空间的概述
- 堆空间关于对象的创建和 GC 的概述
- 堆的细分内存结构
- 设置堆内存大小与OOM
- OOM的说明与举例
- 年轻代与老年代
- 图解对象分配的过程
- 对象分配的特殊情况
- 代码举例与JVisualVM 演示对象的分配过程
- 常用工具概述与Jpofiler 演示
- Minor GC、Major GC与 Full GC
- GC举例与日志分析
- 堆空间分代思想
- 总结内存分配策略
- 为对象分配内存 TLAB
- 堆空间常用参数的设置小结
- 通过逃逸分析看堆空间的对象分配策略
- 代码优化之栈上分配
- 代码优化之同步省略
- 代码优化之标量替换
- 代码优化及堆的小结
- 方法区的概述--栈堆方法区间的交互关系
- 方法区的基本理解
- Hotspot 中方法区的演进
- 设置方法区大小的参数与OOM
- OOM:PermGen 和 Metaspace 举例
- 方法区的内部结构1
- class 文件中常量池的理解
- 运行时常量池的理解
- 图示举例方法区的使用
- 方法区在jdk 6、7、8中的演变
- String Table为什么要调整位置
- 如何证明静态变量存在哪
- 方法区的垃圾回收
- 运行时数据区的总结和常见的大厂面试题
- 对象实例化的几种方式
- 对象创建的 6 个步骤
- 对象的内存布局
- 对象的访问定位
- 直接内存的简单体验
- 使用本地内存读写测试数据
- 直接内存的OOM与内存大小的设置
- 执行引擎的作用以及工作过程概述
- Java 程序的编译和解释运行的理解
- 机器码、指令、汇编、高级语言理解与执行
- 解释器的使用
- Hotspot VM 为何解释器和JIT 编译器共存
- 热点代码探测何时 JIT
- Hotspot 设置模式——C1与C2编译器
- Graal 编译器和 AOT 编译器
- String 的不可变性
- String 底层 HashTable 结构说明
- String 的内存分配
- 字符串的拼接操作的面试题讲解
- 字符串变量拼接的底层原理
- intern 的使用
- new String() 到底创建了几个对象
- 关于 intern 的面试难题
- G1 中 的String 去重操作
- 垃圾回收相关章节的说明
- 什么是 GC ?为什么要使用 GC?
JVM 跨语言的平台
JVM跨语言的平台是什么意思呢?
-
随着Java7的正式发布,Java虚拟机的设计者们通过JSR-292规范基本实现 在Java虚拟机平台上运行非Java语言编写的程序
-
Java虛拟机根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件。也就是说Java 虚拟机拥有语言无关性,并不会单纯地与Java语言“终身绑定”,只要其他编程语言的编译结果 (字节码文件) 满足并包含Java虛拟机的内部指令集、符号表以及其他的辅助信息,它就一个有效的字节码文件,就能够被虚拟机所识别并装载运行。
那么什么是字节码呢?
-
我们平时说的java字节码, 指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码
-
不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的JVM上运行。
-
Java 虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式一Class文件格式所关联, Class 文件中包含了Java 虚拟机指令集(或者称为字节码、Bytecodes) 和符号表,还有一些其他辅助信息
综上,也就是说JVM不是只适用Java语言,其他语言,只要编译出来的字节码文件能被Java 虚拟机识别,符合JVM的规范,就可以使用Java 虚拟机。如下图,JVM 支持多种语言,它们的编译器不一样,最后编译成的字节码文件只有一种,那就是符合 JVM 规范的,统称为 “JVM字节码”
虚拟机与Java虚拟机
虚拟机
所谓虚拟机(Virtual Machine), 就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机
-
大名鼎鼎的Visual Box, VMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
-
程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令。
无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。
Java 虚拟机
什么是Java虚拟机?
- Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。
- JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。
- Java技术的核心就是Java虛拟机(JVM, Java Virtual Machine) ,因为所有的Java程序都运行在Java虚拟机内部。
Java 虚拟机的作用
Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
Java 虚拟机的特点
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
Java 虚拟机的位置
Tip:JVM是运行在操作系统之上的,它与硬件没有直接的交互
Java 虚拟机的整体结构
黄色的部分方法区和堆是所有线程共享的(其实堆里面有部分是线程私有的,可以参见博客:http://www.hollischuang.com/archives/4510
灰色部分代表线程私有,还有基本不占内存空间,不存在垃圾回收机制
下图是Java 8 之前的JVM结构图,因为视频老师是用这个图讲的,所以接下来也是对这张图进行分析。
上图描述的是 Hotspot VM 结构
-
Hotspot VM是目前市面上高性能虚拟机的代表作之一。
-
它采用解释器与即时编译器并存的架构。
-
在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一较高下的地步。
顺便记录一下 Java8 及之前 JVM 结构的区别。。
Java 8 之前与之后Hotspot VM结构的区别
原文链接:https://www.cnblogs.com/july-sunny/p/12628820.html
在讲他们的区别之前,先来认识一个概念“永久代”,对于习惯了在HotSpot虚拟机上开发、部署的程序员来说,很多都愿意将方法区称作永久代。本质上来讲两者并不等价,仅因为Hotspot将GC分代扩展至方法区,或者说使用永久代来实现方法区。在其他虚拟机上是没有永久代的概念的。也就是说方法区是规范,永久代是Hotspot针对该规范进行的实现。如图:
Java 8 与 Java8之前的对比图:
主要区别是元空间取代了永久代
对Java7及以前版本的Hotspot中方法区位于永久代中。同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。
永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。
但在Java7中永久代中存储的部分数据已经开始转移到Java Heap或Native Memory中了。比如,符号引用(Symbols)转移到了Native Memory;字符串常量池(interned strings)转移到了Java Heap;类的静态变量(class statics)转移到了Java Heap。
然后,在Java8中,时代变了,Hotspot取消了永久代。永久代真的成了永久的记忆。永久代的参数-XX:PermSize和-XX:MaxPermSize也随之失效。
对于Java8,HotSpots取消了永久代,那么是不是就没有方法区了呢?
当然不是,方法区只是一个规范,只不过它的实现变了。
在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)
本地内存(Native memory),也称为C-Heap,是供JVM自身进程使用的。当Java Heap空间不足时会触发GC,但Native memory空间不够却不会触发GC。
最后一个问题:Java8 为什么要使用元空间取代永久代/方法区?
原因如下:
- 字符串存在于永久代中,容易出现性能问题和内存溢出
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
- 将 HotSpot 与 JRockit 合二为一
现在回到这张图
主要分三个部分讲解这个图,先做个简单说明
第一部分:类装载器子系统
第二部分:运行时数据区
第三部分:执行引擎
Java 代码的执行流程
JVM的架构模型
HotSpot VM 除了PC寄存器再没有包含其他寄存器了,所以说,Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构
具体来说,这两种架构之间的区别:
- 基于栈的指令集架构的特点
- 设计和实现更简单,适用于资源受限的系统
- 避开了寄存器分配的难题:使用零地址指令分配方式
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小(但是指令更多),编译器容易实现
- 不需要硬件支持,可移植性好,更好实现跨平台
- 基于寄存器的指令集架构的特点
-
典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虛拟机。
-
指令集架构则完全依赖硬件,可移植性差
-
性能优秀和执行更高效:
-
花费更少的指令去完成一项操作。
-
在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。
-
零地址指令、一地址指令、二地址指令什么意思?
一地址指令就是除操作码字段只有一个地址码字段,二地址指令就是有两个地址码字段,零地址指令就是一个地址码字段都没有。
在计算机科学中,一个指令字(或一条指令)一般由两部分组成:一部分用来指明该指令所需完成的操作,这是必不可少的部分,通常被称为操作码部分;另一部分用来指明需进行某种操作的数据(包括输入数据、操作数变量以及所产生结果)来自何处和将被送往何处,这一部分被称为操作致地址码部分。它不一定是必需的。
下图就是大概理解一下这个地址的概念,我暂时只知道这个地址,不太清楚0,1,2是什么意思,后面学完计组再补
看不了这些指令,不过我们可以反编译查看一下程序的汇编语言
查看步骤,打开Terminal,先进入class文件的目录下,然后Javap 反编译就可以了
我的目录结构:
具体步骤:
1. cd target/classes/Other
2. javap -v TestXp.class
小总结:
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台, 指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台了(准确来说应该是HotSpotVM的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?
JVM的生命周期
JVM 生命周期主要有三个状态:虚拟机的启动、执行、退出
接下来就简单聊聊这三种状态
虚拟机的启动
Java虚拟机的启动是通过引导类加载器(bootstrap class loader) 创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
虚拟机的执行
- 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。
- 程序开始它才运行,程序结束他就停止
- 执行一个所谓的Java程序的时候,真真正正执行的是一个叫做Java虚拟机的进程
虚拟机的退出
有如下的几种情况:
-
程序正常执行结束
-
程序在执行过程中遇到了异常或错误而异常终止
-
由于操作系统出现错误而导致Java虚拟机进程终止
-
某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。
-
除此之外,JNI ( Java Native Interface)规范描述了用JNI Invocation API来加载或卸载Java 虛拟机时,Java 虚拟机的退出情况。
内存结构概述
详解英文版:
详解中文版:
概述类的加载器和类加载过程
类加载器子系统分为三个阶段:
- 加载阶段:查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象
- 链接阶段:连接又包含三块内容:验证、准备、初始化
(1)验证:文件格式、元数据、字节码、符号引用验证
(2)准备:为类的静态变量分配内存,并将其初始化为默认值
(3)解析:把类中的符号引用转换为直接引用 - 初始化阶段:为类的静态变量赋予正确的初始值
类加载器子系统的作用
- 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识(这个标识在链接中的验证阶段被判断,.class 文件的开头必须是CA FE BA BE)。
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine 决定
- 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
类加载器 ClassLoader角色
-
class file存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
-
class file加载到JVM中,被称为DNA元数据模板,放在方法区。
-
在.class文件-> JVM ->最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader) ,扮演一个快递员的角
类的加载过程
加载(Loading)
- 通过一个类的全限定类名获取定义此类的二进制字节流
- 将这个类所代表的静态存储结构转化为方法区得运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
链接(Linking)
链接又分为三个阶段:验证、准备、解析
(1)验证(Verify):
- 目的在于确保Class文件的字节流中包含的信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全
- 主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证
(2)准备(Prepare):
- 为类变量分配内存并且设置该类变量的初始值,即零值
- 这里不包含用 final 修饰的 static ,因为 final 在编译的时候就会分配了,准备阶段会显式初始化
- 这里不会为实例变量分配初始化,类变量会分配在方法区,而实例变量会随着对象一起分配到 Java 堆中
(3)解析(Resolve):
- 将常量池内的符号引用替换成直接引用的过程
- 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等
解析中提到符号引用,顺便记录一下符号引用什么意思
- 什么是符号引用?在说符号引用之前我们先来看看直接引用,直接引用是什么,比如就是你拥有你所需要数据的地址值,可以直接根据地址值获取到数据。但是 java语言是解释性的语言,然后由于总总原因(我也不知道对不对的原因)在某些时刻有些东西的直接地址还并不存在,是无法使用直接引用。这时候就可以用到符号引用了。
- 符号引用:符号引用是一个字符串,它给出了被引用的内容的名字并且可能会包含一些其他关于这个被引用项的信息——这些信息必须足以唯一的识别一个类、字段、方法。这样,对于其他类的符号引用必须给出类的全名。对于其他类的字段,必须给出类名、字段名以及字段描述符。对于其他类的方法的引用必须给出类名、方法名以及方法的描述符。这样我们就能根据符号引用锁定唯一的类,方法或字段了
原文链接:https://blog.csdn.net/u014296316/article/details/83066436
初始化(Initialization)
- 初始化阶段就是执行类构造器方法
<clinit>()
的过程 - 此方法不需要定义,是Javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
- 构造器方法中指令按语句在源文件中出现的顺序执行
<clinit>()
不同于类的构造器。(关联:构造器是虚拟机视角下的<init>()
)- 若该类具有父类,JVM 会保证子类的
<clinit>()
执行前,父类的<clinit>()
已经执行完毕 - 虚拟机必须保证一个类的
<clinit>()
方法在多线程下被同步加锁
类加载器
类加载器的分类
-
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader) 。
-
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
-
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示:
虚拟机的分类
虚拟机自带的加载器
启动类加载器(引导类加载器,Bootstrap ClassLoader)
-
这个类加载使用C/C++语言实现的,嵌套在JVM内部。
-
它用来加载Java的核心库(JAVA_ HOME/jre/lib/rt.jar、resources. jar或sun . boot. class . path路径下的内容) ,用于提供JVM自身需要的类
-
并不继承自java. lang.ClassLoader,没有父加载器。
-
加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
-
出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器( Extension ClassLoader )
- Java语言编写,由sun. misc. LauncherSExtClassLoader实现。
- 派生于ClassLoader类
- 父类加载器为启动类加载器
- 从java.ext .dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。也就是说扩展类加载器主要就是识别那个目录
应用程序类加载器(系统类加载器,AppClassLoader)
-
java语言编写,由sun. misc. Launcher$AppClassLoader实现
-
派生于ClassLoader类
-
父类加载器为扩展类加载器
-
它负责加载环境变量classpath或系统属性java .class.path指定路径下的类库
-
该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
-
通过ClassLoader #getSystemClassLoader ()方法可以获取到该类加载器
用户自定义类加载器
- 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器?
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄漏
用户自定义类加载器实现步骤:
-
开发人员可以通过继承抽象类java. lang. ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
-
在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中
-
在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
双亲委派机制
Java虛拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虛拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
工作原理:
-
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
-
如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
-
如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模
优势:
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
采用双亲委派机制,有的不仅仅用到了引导、扩展类加载器路径下的东西,就可以反向委托,比如,核心的 rt.jar 包用启动类加载器的,其他的那两个加载器没有的,通过反向委托,就用自己的(系统类加载器),具体流程如下图
沙箱机制
参考博客:https://m.qukuaiwang.com.cn/news/14003.html
为什么需要沙箱
默认情况下,一个应用程序是可以访问机器上的所有资源的,比如CPU、内存、文件系统、网络等等。
但是这是不安全的,如果随意操作资源,有可能破坏其他应用程序正在使用的资源,或者造成数据泄漏。为了解决这个问题,一般有下面两种解决方案:
(1) 为程序分配一个限定权限的账号:利用操作系统的权限管理机制进行限制
(2) 为程序提供一个受限的运行环境:这就是沙箱机制
什么是沙箱机制
沙箱就是一个限制应用程序对系统资源的访问的运行环境。
沙箱很多情况下都是实现在虚拟机(VM)中,比如Java的虚拟机JVM、Javascript的虚拟机V8引擎、Android中的虚拟机Dalvik/ART,以及以太坊的虚拟机EVM等等。具体的实现方式各有不同,本文重点分析一下JVM和EVM的沙箱机制实现。
沙箱机制是由基于双亲委派机制上 采取的一种JVM的自我保护机制,假设你要写一个java.lang.String 的类,由于双亲委派机制的原理,此请求会先交给Bootstrap试图进行加载,但是Bootstrap在加载类时首先通过包和类名查找rt.jar中有没有该类,有则优先加载rt.jar包中的类,因此就保证了java的运行机制不会被破坏.
类的主动使用与被动使用
类的主动使用和被动使用的区别就是会不会导致类的初始化
在JVM中表示两个class对象是否为同–个类存在两个必要条件:
-
类的完整类名必须一致,包括包名。
-
加载这个类的ClassLoader (指ClassLoader实例对象)必须相同。
换句话说,在JVM中, 即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
对类加载器的引用
JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的候,JVM需要保证这两个类型的类加载器是相同的。
类的主动使用和被动使用:
Java程序对类的使用方式分为:主动使用和被动使用。
主动使用,又分为七种情况:
➢ 创建类的实例
➢ 访问某个类或接口的静态变量,或者对该静态变量赋值
➢ 调用类的静态方法
➢ 反射(比如: Class . forName (“com. atguigu. Test") )
➢ 初始化一个类的子类
➢ Java虛拟机启动时被标明为启动类的类
➢ JDK 7开始提供的动态语言支持:
java. lang. invoke . MethodHandle实例的解析结果、REF getStatic、 REF putStatic、REF invokeStatic句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化。
运行时数据区概述及线程开始
运行时数据区的内部结构
内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存
JVM 中的线程说明
线程终止之后,JVM 还有做一件事,就是判断JVM要不要终止
PC 寄存器概述
PC寄存器是每个线程有一份
PC寄存器的作用就是存储下一条指令的地址
PC 寄存器的使用用例
- 左边就是PC寄存器中所存储的东西,右边就是操作指令
PC 寄存器中的两个面试问题
虚拟机栈
- 栈:局部变量表、操作数栈
虚拟机栈的常见异常和如何设置栈大小
栈的存储结构和运行原理
栈帧的内部结构
- 栈帧是有大小的,取决于他内部结构的大小
那么他内部结构都有什么呢?
栈帧的内部分成了五部分结构:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 一些附加信息
- 动态链接、 方法返回地址、一些附加信息有些地方统称为帧数据区
- 栈帧当中占据空间比较大的就是局部变量表
局部变量表
变量槽 slot 的理解与演示
Tip: 作用域是从定义之后的下一行开始的,比如下图,a 是在19行定义的,他的作用域范围就是 20-23,作用域范围是从20开始的,而不是19
静态变量与局部变量的对比及小结
操作数栈
- 操作数栈就是从局部变量表中取值,做运算,再写回局部变量表中
- 前面所说执行引擎是基于栈的执行引擎,这个栈指的就是操作数栈
- 操作数栈中的数据类型必须与字节码序列严格匹配,float对应一个slot,double 对应两个 slot 这样的
- Tip:上图 bipush 就是入栈,入的就是操作数栈,istore 存储,存在局部变量表里面,iadd 就是取出来做加的操作
涉及操作数栈的字节码指令分析
- byte 、short、char、boolean 保存到数组的时候,都是以 int 型来存
- 如下图, byte i 的字节码指令是 bipush 。push 是进栈,b是指 byte ,i 是指 int
- 还有一句 istore_1 ,i 就是 int 、_1 是指索引为1的位置,所以说明往局部变量表里面存的就是个int
- iadd 的时候需要执行引擎把字节码指令翻译成机器指令,然后由CPU来做这个加的运算
有个小问题,如果定义一个 int i=8 的话,byte 就存的下,所以是 bipush。
如果定义 int i=8000,short 能存下,就是 sipush
超过 short 能存的,才是 ipush
如果被调用方法有返回值的话,其返回值会被压入当前栈帧的操作数栈中
public class ClassInitTest {
public int getA()
{
int a=10;
return a;
}
public void tmp()
{
getA();
}
public static void main(String[] args) {
new ClassInitTest().tmp();
}
}
tmp 方法的字节码文件:
在非静态方法中, aload_0 表示对this的操作,在static 方法中,aload_0表示对方法的第一参数的操作
- aload_0 一上来就把this加载到了操作数栈中,invokevirtual #2 <ClassInitTest.getA> 调用 方法getA
栈顶缓存技术
- 这只是一个想法。。目前还没有实现,了解
- 零地址指令,只有入栈出栈两种指令,不需要地址
- 栈式结构,代码指令多,IO操作就多,为了减少IO操作,引入了栈顶缓存技术
动态链接
Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
- 字节码文件中专门有个区域叫常量池
- 最开始把每一个方法都当做一个引用,比如 getA() 在常量池里面就用 #10代替(随便选的一个数),有要用到 getA() 的,直接调用 #10 就可以,这就是符号引用。也就是上面那句话:在Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在 class 文件的常量池中
下面这张图,符号引用指向了具体的方法、属性等,感觉右边那些具体的应该在堆里面,常量池还是只存了引用。个人感觉,方法区就是存放的运行时常量池,但是还是存的引用,方法区就像一本书的目录,其他的指向这个目录,这个目录指向具体的内容(堆里面的具体对象)
在code 里面的时候,通过#数字,一下就指向了常量池中对应的符号引用法的引用。
方法的绑定机制:静态绑定与动态绑定(方法的调用)
- 静态绑定对应早期绑定、动态绑定对应晚期绑定
- 动态绑定就像实现接口的时候,要运行的时候才知道(多态的特性就是典型的晚期绑定)
- 虚函数的特征体现在运行期才能确定下来
class Animal{
public void eat(){
System.out.println("动物进食");
}
}
interface Huntable{
void hunt();
}
class Dog extends Animal implements Huntable{
@Override
public void eat() {
System.out.println("狗吃骨头");
}
@Override
public void hunt() {
System.out.println("捕食耗子,多管闲事");
}
}
class Cat extends Animal implements Huntable{
public Cat(){
super();//表现为:早期绑定
}
public Cat(String name){
this();//表现为:早期绑定
}
@Override
public void eat() {
super.eat();//表现为:早期绑定
System.out.println("猫吃鱼");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天经地义");
}
}
public class AnimalTest {
public void showAnimal(Animal animal){
animal.eat();//表现为:晚期绑定
}
public void showHunt(Huntable h){
h.hunt();//表现为:晚期绑定
}
}
四种方法调用指令区分非虚方法与虚方法
- 虚方法和晚期绑定是对应的
- static 、private、final、构造方法、super(). 方法名 方法都是非虚方法
- 非虚方法就是编译期不能确定的方法
- final 调用的时候也是 invokevirtual ,但是他是非虚方法
class Father {
public Father() {
System.out.println("father的构造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father 普通方法");
}
}
public class Son extends Father {
public Son() {
//invokespecial
super();
}
public Son(int age) {
//invokespecial
this();
}
//不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic(String str) {
System.out.println("son " + str);
}
private void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
//invokestatic
showStatic("atguigu.com");
//invokestatic
super.showStatic("good!");
//invokespecial
showPrivate("hello!");
//invokespecial
super.showCommon();
//invokevirtual
showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
//虚方法如下:
//invokevirtual
showCommon();
info();
MethodInterface in = null;
//invokeinterface
in.methodA();
}
public void info(){
}
public void display(Father f){
f.showCommon();
}
public static void main(String[] args) {
Son so = new Son();
so.show();
}
}
interface MethodInterface{
void methodA();
}
invokedynamic 指令的使用
前面说了虚拟机中的调用指令分为普通指令和动态调用指令
方法重写的本质与虚方法表的使用
- 虚方法表是在链接阶段的解析阶段被创建的
- 虚方法表就是存的直接引用,重写了的,引用指向自身,没重写的,引用直接指向父类(就是继承最上面的那个重写了的父类,或者就是这个方法源头的那个父类)
- 虚方法表什么意思呢,例子一,Son 继承了 Father 类,Father类继承了Object类,Father和Son都重写了 hardChoice 方法,在虚方法表里面,这个重写了的方法就指向的Father 和Son ,其他没有重写的方法就还是指向的Object,直接就能找到
- 例子二,cat 和 CockerSpaniel 都实现了Friday接口
cat 类:
方法返回地址说明
from to target type
从from行到to 行是按照target来处理的,针对type类型
一些附加信息
虚拟机栈的5道面试题
- 分配的栈并不是内存越大越好,对自己好,其他的栈的空间就小了,其他线程的数量就得减少了。毕竟整个内存空间是有限的
- 方法中定义的局部变量是否线程安全?有时候不安全
/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
* @author shkstart
* @create 2020 下午 7:48
*/
public class StringBuilderTest {
int num = 10;
//s1的声明方式是线程安全的
public static void method1(){
//StringBuilder:线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作过程:是线程不安全的
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是线程不安全的
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}
本地方法接口的理解
本地方法栈的理解
堆空间的概述
- 一个进程对应一个JVM实例,方法区和堆是进程私有,线程共享的。
- 程序计数器、本地方法栈、虚拟机栈是线程私有的
堆空间关于对象的创建和 GC 的概述
什么是TLAB
TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)是 Java 中内存分配的一个概念,它是在 Java 堆中划分出来的针对每个线程的内存区域,专门在该区域为该线程创建的对象分配内存。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。TLAB 本质上还是在 Java 堆中的,因此在 TLAB 区域的对象,也可以被其他线程访问。
如果没有启用 TLAB,多个并发执行的线程需要创建对象、申请分配内存的时候,有可能在 Java 堆的同一个位置申请,这时就需要对拟分配的内存区域进行加锁或者采用 CAS 等操作,保证这个区域只能分配给一个线程。
启用了 TLAB 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域,在预留这个动作发生的时候,需要进行加锁或者采用 CAS 等操作进行保护,避免多个线程预留同一个区域。一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作。
原文链接:https://blog.csdn.net/hfer/article/details/106077631
- 垃圾回收线程执行的时候,用户线程要被暂停
- 垃圾回收的时候,垃圾才会被清理掉,垃圾回收的频率不易过高
- 堆是垃圾回收的重点区域
- 大内存和频繁GC ,重点关注堆
堆的细分内存结构
- 只是逻辑上分成新生区、养老区和永久区(元空间),实际上永久区和元空间都不在堆里面
PERM GEN :永久代
METASPACE:元空间
用指令查看Java7、8的区别
Java7:
Java8:
设置堆内存大小与OOM
- 开发中建议把初始内存和最大内存设置成相同值,因为扩容会给系统带来压力,不想他扩容,避免GC之后还要调整堆内存的大小,造成系统额外的压力
- 伊甸园区和幸存者区总有一个是空的
/**
* 1. 设置堆空间大小的参数
* -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
* -X 是jvm的运行参数
* ms 是memory start
* -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
*
* 2. 默认堆空间的大小
* 初始内存大小:物理电脑内存大小 / 64
* 最大内存大小:物理电脑内存大小 / 4
* 3. 手动设置:-Xms600m -Xmx600m
* 开发中建议将初始堆内存和最大的堆内存设置成相同的值。
*
* 4. 查看设置的参数:方式一: jps / jstat -gc 进程id
* 方式二:-XX:+PrintGCDetails
* @author shkstart shkstart@126.com
* @create 2020 20:15
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");
// System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
// System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
OOM的说明与举例
年轻代与老年代
- 有的对象生命周期特别长,每次GC都去判断他是否要回收就是在浪费时间,所以把这种生命周期特别长的就放到不常回收(GC)的地方,像老年区
- 伊甸园区:对象开始的地方(亚当、夏娃)
- NewRatio就是控制新生代和老年代的比例的(老年代/新生代)
- 默认:yong区占1/3,old 占2/3。yong 里面细分为Eden:from:to=8:1:1
- 但是你用指令查看的时候,Eden:from:to=6:1:1
/**
* -Xms600m -Xmx600m
*
* -XX:NewRatio : 设置新生代与老年代的比例。默认值是2.
* -XX:SurvivorRatio :设置新生代中Eden区与Survivor区的比例。默认值是8
* -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略 (暂时用不到,+就是使用,-就是不用)
* -Xmn:设置新生代的空间的大小。 (一般不设置)
*
* @author shkstart shkstart@126.com
* @create 2020 17:23
*/
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("我只是来打个酱油~");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
图解对象分配的过程
- YGC 有个过程:STW
- 从 Eden 到 S0 ,age 赋值为1
- 第一次Eden满的时候放在 S0中,第二次满了,Eden 中的数据放在 S1 中,同时会去判断 S0 区,会把S0 区的数据放在 S1中(age<=15次),或养老区中(age>15,晋升(Promotion)),或者直接被当成垃圾回收掉。这时候S0区的数据就被清空了,空的那个就叫做 to 区(谁空谁是 to),每次Eden区的数据都是往 S0 区放
- 什么时候触发 YGC 呢? 当 Eden 区满的时候
- 幸存区满的时候不会触发 YGC,但是当 Eden 区满触发YGC的时候就会将 S0 区的数据一起进行回收,所以S0区是属于被动回收的。
- 如果幸存者区满了的话,有个特殊的处理方法,下面讲到的:对象分配的特殊情况,直接晋升老年区
对象分配的特殊情况
前面讲的是对象分配的一般情况
如果对象特别特别大,Eden区进行了GC之后还是放不下,就会直接放到Old 区,如果Old区放不下,先进行 major GC, major GC之后还是放不下,就会抛出OOM 错误。
Eden 区进行 GC 之后,存活的要被放在S0 区,如果某个对象太大, S0区放不下,就会让它直接晋升老年区
具体流程如下图 :
代码举例与JVisualVM 演示对象的分配过程
常用工具概述与Jpofiler 演示
- Profiling Gradle run configurations did not work anymore since IDEA 2019.3( JProfiler 不起作用了?)
Minor GC、Major GC与 Full GC
针对于Eden 区的:Minor GC
针对Old 区的:Major GC
- 调优就是希望 GC 的时候能少一些
- 重点就是对 Major GC 和 Full GC进行调优,因为他们执行的时间是Minor GC 的好几倍(GC 线程执行的时候,用户线程要暂停)
- 现在大多数虚拟机在进行 Major GC 的时候并不仅仅只是回收老年代的垃圾,可能还有其他垃圾的回收,目前只有CMS GC会有单独收集老年代的行为
- G1 GC 会有混合收集是因为堆按regin(区域)划分的,新生区和老年区混合在一起了
- 方法区回收的主要是一些不用的类和类加载器
GC举例与日志分析
- 字符串常量池以前在方法区,现在在堆空间
参数:-XX:+PrintGCDetails
代码:
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable t) {
t.printStackTrace();
//System.out.println("遍历次数为:" + i);
}
}
}
日志信息:
[GC (Allocation Failure) [PSYoungGen: 143232K->24796K(179200K)] 143232K->90980K(588800K), 0.0273914 secs] [Times: user=0.09 sys=0.02, real=0.03 secs]
[GC (Allocation Failure) [PSYoungGen: 162936K->2226K(179200K)] 499457K->383802K(588800K), 0.0205144 secs] [Times: user=0.06 sys=0.00, real=0.02 secs]
[Full GC (Ergonomics) [PSYoungGen: 2226K->0K(179200K)] [ParOldGen: 381576K->270996K(409600K)] 383802K->270996K(588800K), [Metaspace: 3465K->3465K(1056768K)], 0.0480834 secs] [Times: user=0.31 sys=0.00, real=0.05 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(179200K)] 270996K->270996K(588800K), 0.0015490 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(179200K)] [ParOldGen: 270996K->270977K(409600K)] 270996K->270977K(588800K), [Metaspace: 3465K->3465K(1056768K)], 0.0047072 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 179200K, used 7615K [0x00000000f3800000, 0x0000000100000000, 0x0000000100000000)
eden space 153600K, 4% used [0x00000000f3800000,0x00000000f3f6ffa8,0x00000000fce00000)
from space 25600K, 0% used [0x00000000fce00000,0x00000000fce00000,0x00000000fe700000)
to space 25600K, 0% used [0x00000000fe700000,0x00000000fe700000,0x0000000100000000)
ParOldGen total 409600K, used 270977K [0x00000000da800000, 0x00000000f3800000, 0x00000000f3800000)
object space 409600K, 66% used [0x00000000da800000,0x00000000eb0a07a8,0x00000000f3800000)
Metaspace used 3496K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 384K, capacity 388K, committed 512K, reserved 1048576K
以后记得补分析
堆空间分代思想
总结内存分配策略
为对象分配内存 TLAB
TLAB :英文翻译过来就是线程本地分配缓存区
- 上图,是每一个线程仅占Eden空间的1%
- 堆空间不都是线程共享的,TLAB是堆上的,他是线程私有的
堆空间常用参数的设置小结
空间分配担保:-XX:HandlePromotionFailure
通过逃逸分析看堆空间的对象分配策略
堆是分配对象存储的唯一选择吗?
- 逃逸分析手段就是用来确定对象要不要在栈上分配的
代码优化之栈上分配
代码优化之同步省略
- 就是编译器会自动帮你把加锁操作消除(这个方法也叫同步消除)
代码优化之标量替换
- 对象可以不用存在堆空间,栈空间也可以
- String 底层是 char数组, jdk9 以后就变成了 byte 数组
- 可以放到栈空间的对象,也可以被肢解成标量,把一个对象的属性都提取出来,就可以放到局部变量表了,还可以分散分布在栈上
- 相当于一个栈上分配的变形
代码优化及堆的小结
- 逃逸分析在 jdk1.7 之后自动添加,注意的是这个自动添加是针对服务器端来说的,服务器端才有逃逸分析,客户端没有
- TaoBao VM 的GCIH 就很好应用了逃逸分析
- 所以我们现在仍然认为对象是分配在堆上的
- Major GC 主要回收老年代,Full GC主要是整个堆,包括方法区
- jdk8 之后 intern 字符串缓存和静态变量直接在堆上分配
方法区的概述–栈堆方法区间的交互关系
运行时数据区就剩下方法区
方法区的基本理解
- 逻辑上,把方法区看成堆的一部分,但是方法区可以不考虑GC、和压缩算法,所以把方法区独立于堆存在
- Java8 的JVM没有限定方法区的具体位置,也就是说没有规定方法区到底要不要是堆的一部分
- 共享说明类只需要加载一次
Hotspot 中方法区的演进
- 可以把方法区看成一个接口,把永久代和元空间看成一个实现
- 元空间使用的本地内存,而非Java 虚拟机的内存
- 方法区和永久代并不等价,方法区是规范,永久代是具体实现,但是对Hotspot 来说可以看成等价的
设置方法区大小的参数与OOM
OOM:PermGen 和 Metaspace 举例
方法区的内部结构1
- 域信息和方法信息可以看成是涵盖在类信息中的
- 上面的图片,现在来说有一些变化了的
- 类型信息包括:类的全限定类名,父类的全限定类名、这个类的修饰符、这个类实现的所有接口(因为可以实现多个接口,所以作为一个序列表存在)
- 域信息,就是我们平时说的成员变量
class 文件中常量池的理解
运行时常量池的理解
常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
- 类和接口的全限定名
- 字段名称和描述符
- 方法名称和描述符
java中基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character。前四种包装类默认创建了数值[-128,127]的相应类型的缓存数据,Character创建了[0,127]的相应的缓存数据,但是超出此范围仍然会去创建新的对象。 两种浮点数类型的包装类Float,Double并没有实现常量池技术。
- byte的取值范围是-128到+127
static final Byte cache[] = new Byte[-(-128) + 127 + 1];
static final Short cache[] = new Short[-(-128) + 127 + 1];
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
...
high=127;
static final Long cache[] = new Long[-(-128) + 127 + 1];
static final Character cache[] = new Character[127 + 1];
- JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的,如下图,从【01】开始的常量池(下图是class文件中的常量池,不过索引都差不多,主要想记录那个索引长什么样)
图示举例方法区的使用
-
本地变量表相当于一个数组
-
常量池中最开始都是存的符号引用,存的一些字符串,加载进内存后被替换成直接引用的
-
字节码指令是存放在方法区的吗?
方法区在jdk 6、7、8中的演变
-
jdk8 及以后,静态变量仍在堆中
-
从 jdk8 开始,元空间使用本地内存,不再使用虚拟机的内存了
永久代为什么要被元空间替换?
- 官网中的说法是:JRockit 被收购后,为了融合 JRockit 和 Hotspot
- 下图是官网的翻译
- 对永久代调优是很困难的,要判断哪些常量和类元信息不用了,进行Full GC ,是很麻烦费时间的
String Table为什么要调整位置
interned String:字符串字面量
- 方法区回收效率不高,字符串不能及时被回收,容易导致内存不足,放到堆里,能及时回收
如何证明静态变量存在哪
- 只要是 new 的对象都是放在堆空间中,静态变量也不例外
方法区的垃圾回收
- 方法区的垃圾回收主要回收两部分内容:常量池中废弃的常量和不再使用的类型
- 关于方法区是否要垃圾回收,Java虚拟机规范没有明说,也就是你可以回收,也可以不回收
运行时数据区的总结和常见的大厂面试题
说一下JVM 内存模型,有哪些区?分别干什么的?
https://baijiahao.baidu.com/s?id=1657944032549248047&wfr=spider&for=pc
Java 虚拟机在执行Java程序的过程中会把他所管理的内存划分为若干个不同的数据区域
Java 虚拟机规范将 JVM 所管理的内存分为以下几个运行时数据区:
- 程序计数器
- Java 虚拟机栈
- 本地方法栈
- Java 堆
- 方法区
这是经典的JVM 的划分方法,Jdk8 之后有所变化,例如方法区,jdk8 以前方法区的实现是永久代,jdk8 及以后叫做元数据区,方法区的变化其实是从jdk7 开始的,jdk7 把字符串常量池和静态变量移到了堆中,jdk8正式去除永久代,变为元数据区,并从虚拟机内存中独立出来,存放在本地内存中
接下来说说他们的作用
方法区和堆是线程共享的
程序计数器、本地方法栈、虚拟机栈是线程私有的
1、方法区:
方法区在JDK1.8之后把名字改成了“Metaspace",可以翻译成"元数据空间",这是一块线程共享的内存空间,主要用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。
Java虚拟机规范将方法区描述为堆的一个逻辑部分,但它有一个别名”Non-Heap 非堆“ 用于与Java堆区分。
很多人愿意把方法区成为”永久代“,本质上两者并不等价。HotSpot虚拟机 团队选择把GC分代收集扩展至方法区,或者说使用永久带实现方法区而已。
待补
对象实例化的几种方式
- newInstance在Java9中显示已经过时了
从字节码的角度解析
-
new :首先会判断Object这个类是不是被加载了,如果没有就要用类加载器把它加载进来,再在堆中把Object这个对象的空间开辟好(对象的大小可以确定下来的),还要做什么事情呢,比如零值初始化。
-
dup :复制,操作数栈,一方面是生成对象的引用,然后把这个引用复制一遍,就有两个引用指向对象的实体,为什么要两个引用呢,压在栈底的用作赋值操作,上面的那个作为一个句柄,去调用相关的方法
-
invokespecial :方法调用指令中的一个,调用 Object 中的 init 方法。显示初始化就是在这里出现的(上面是初始化为0值,这里是真正赋值)
-
astore_1 :从操作数栈中把 object 变量取出来,放到局部变量表中
对象创建的 6 个步骤
对象的内存布局
对象的访问定位
- 对象访问有两种方式:句柄访问和直接指针,Java 虚拟机规范并没有明确规定使哪种,所以不同的虚拟机对象访问可能就不太一样,HotSpot 采用的是直接指针
- 句柄访问浪费空间,查找效率还低,查对象实体要查两次
- 但是句柄访问有个好处,reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针,reference 本身不需要被修改
对象访问图示:
句柄访问图示:
直接访问图示:
直接内存的简单体验
- IO:阻塞式的
- NIO:非阻塞式的
/**
* IO NIO (New IO / Non-Blocking IO)
* byte[] / char[] Buffer
* Stream Channel
*
* 查看直接内存的占用与释放
* @author shkstart shkstart@126.com
* @create 2020 0:22
*/
public class BufferTest {
private static final int BUFFER = 1024 * 1024 * 1024;//1GB
public static void main(String[] args){
//直接分配本地内存空间
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
System.out.println("直接内存分配完毕,请求指示!");
Scanner scanner = new Scanner(System.in);
scanner.next();
System.out.println("直接内存开始释放!");
byteBuffer = null;
System.gc();
scanner.next();
}
}
使用本地内存读写测试数据
怎么理解访问直接内存的速度会优于Java堆,即读写性能高?如下图
下图也算 IO 和 NIO 的区别
public class BufferTest1 {
private static final String TO = "F:\\test\\异界BD中字.mp4";
private static final int _100Mb = 1024 * 1024 * 100;
public static void main(String[] args) {
long sum = 0;
String src = "F:\\test\\异界BD中字.mp4";
for (int i = 0; i < 3; i++) {
String dest = "F:\\test\\异界BD中字_" + i + ".mp4";
// sum += io(src,dest);//54606
sum += directBuffer(src,dest);//50244
}
System.out.println("总花费的时间为:" + sum );
}
private static long directBuffer(String src,String dest) {
long start = System.currentTimeMillis();
FileChannel inChannel = null;
FileChannel outChannel = null;
try {
inChannel = new FileInputStream(src).getChannel();
outChannel = new FileOutputStream(dest).getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
while (inChannel.read(byteBuffer) != -1) {
byteBuffer.flip();//修改为读数据模式
outChannel.write(byteBuffer);
byteBuffer.clear();//清空
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (inChannel != null) {
try {
inChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (outChannel != null) {
try {
outChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
long end = System.currentTimeMillis();
return end - start;
}
private static long io(String src,String dest) {
long start = System.currentTimeMillis();
FileInputStream fis = null;
FileOutputStream fos = null;
try {
fis = new FileInputStream(src);
fos = new FileOutputStream(dest);
byte[] buffer = new byte[_100Mb];
while (true) {
int len = fis.read(buffer);
if (len == -1) {
break;
}
fos.write(buffer, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fis != null) {
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
long end = System.currentTimeMillis();
return end - start;
}
}
直接内存的OOM与内存大小的设置
- 从逻辑上,堆是包含方法区的,但实际上方法区不受堆空间最大值参数设置的影响
- 直接内存的最大值默认和堆空间设置的最大值一样
- Java进程的空间=本地内存空间+堆空间
- 常见的OOM:
- 如果抛OOM 异常,不知道什么原因,可以查看 dump 文件,如果 dump 文件很小,你又遇到了OOM 异常,那么你就可以考虑自己是不是直接或间接用到了本地内存,因为本地内存溢出看不到,你可以怀疑怀疑他
/**
* 本地内存的OOM: OutOfMemoryError: Direct buffer memory
*/
public class BufferTest2 {
private static final int BUFFER = 1024 * 1024 * 20;//20MB
public static void main(String[] args) {
ArrayList<ByteBuffer> list = new ArrayList<>();
int count = 0;
try {
while(true){
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
list.add(byteBuffer);
count++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
System.out.println(count);
}
}
}
执行引擎的作用以及工作过程概述
- 违反双亲委派的例子?
- 执行引擎就是个翻译
- JIT 编译称为后端编译,Java 程序编译成字节码文件称为前端编译
Java 程序的编译和解释运行的理解
-
不同的颜色代表不同的路径
-
Java 是半编译半解释的语言。绿色代表Java 的解释运行部分,蓝色代表编译阶段。橙色和Java虚拟机没有关系,绿色和蓝色才是JVM 要考虑的
-
下图其实就是Javac 前段编译的执行过程
-
Java 是半编译半解释型语言那个编译不是指的把 .java 文件编译成 .class 文件,而是指的JIT编译器的编译。就是在解释给CPU运行的时候,我们即可以使用解释器,也可以使用编译器
机器码、指令、汇编、高级语言理解与执行
上图的编译过程和汇编过程这两个词最先来源于C++,如下图
解释器的使用
- 为什么要有 class 文件,为什么用class 文件来实现跨平台?直接用汇编语言也可以实现跨平台,为什么不直接用汇编语言?老师说是为了前后端分离,提高效率(前端就是指编译成字节码【前端部分不属于JVM】,后端就是从字节码开始之后的)
模板解释器如下:
为什么 JIT编译器很好了还要解释器呢?接下来,让我们看看JIT 编译器的优缺点
Hotspot VM 为何解释器和JIT 编译器共存
- 前面我们讲过,橙色部分就是 javac 把 .java 编译成 .class 的过程,解释器就是绿色的那部分,现在我们来讲编译器,也就是蓝色的部分
- JIT 相比较 解释器有个很显著的优势,就是执行效率快,因为直接执行的机器码,解释器还要逐行翻译
接下来我们再对编译器的细节做一个讲解
热点代码探测何时 JIT
- 根据热点探测功能,将有价值的的字节码编译成本地机器指令,以换取更高程序的执行效率
什么时候会让JIT去做及时编译?
- 执行频率高的代码叫热点代码,那么多高的频率才算高呢?
方法调用计数器的描述如下图:
那这里还有一个问题,前面设置的那个阈值,只要程序一直执行,肯定就会达到那个阈值,最后岂不是所有的方法都可以进行 JIT 了吗?当然不是,这就涉及到了热度衰减,如下图
回边计数器为啥是两计数器之和超过阈值捏?
Hotspot 设置模式——C1与C2编译器
- 在 HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler 和 Server Compiler ,但大多数情况下我们简称为 C1 编译器和C2 编译器
- 在 64 位操作系统下,设置成 -client 会被忽略,只能是默认的 -server
方法内联:就是本来是一个栈帧对应一个方法,我们现在把方法融合在一起了,减少栈帧的生成
- C2 编译器是用 C++ 来编写的
Graal 编译器和 AOT 编译器
- Graal 虚拟机可能会取代 Hotspot 虚拟机
- 不同平台的指令集不一样
String 的不可变性
- jdk1.8 String 的底层实现是 private final char value[];
- jdk1.9 开始,String 底层实现是private final byte value[]; 一个是 char ,一个是 byte
jdk 1.9 发生变更的原因是什么?
翻译一下就是说,字符串是堆空间的主要组成部分,大多数String 对象都是拉丁文字母,这种字母一个字节就能存的下,一个 char 有两个字节,所以大多数时候,我们都浪费了一半的空间
所以我们就做了改变,把 UTF-8 的 char 数组变成了 byte 数组加一个编码标识符,因为汉字还是需要两个细节嘛,就用这个标识符判断是不是汉字,是的话就还是用 utf-8 两个字节去存,否则就用一个字节去存
- 通过字面量定义字符串的方法,字符串存放在常量池
String 底层 HashTable 结构说明
- String pool 是一个固定大小的 HashTable
- String pool 长度固定,不会扩容
- jdk 8 以前对StringTableSize没有要求,从 jdk8 开始,1009 是可设置的最小值
String 的内存分配
字符串的拼接操作的面试题讲解
- 只要拼接操作中有一个是变量,结果就在堆中(常量池也在堆中,但是不是常量池那块空间)
- 如果拼接符号的前后出现了变量,则相当于在空间中 new String() ,具体的内容为拼接后的结果
import org.junit.Test;
/**
* 字符串拼接操作
* @author shkstart shkstart@126.com
* @create 2020 0:59
*/
public class StringTest5 {
@Test
public void test1(){
String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
/*
* 最终.java编译成.class,再执行.class
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); //true
System.out.println(s1.equals(s2)); //true
}
@Test
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";//编译期优化
//如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
/*
体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
详情:① StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
*/
@Test
public void test6(){
long start = System.currentTimeMillis();
// method1(100000);//4014
method2(100000);//7
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
}
public void method1(int highLevel){
String src = "";
for(int i = 0;i < highLevel;i++){
src = src + "a";//每次循环都会创建一个StringBuilder、String
}
// System.out.println(src);
}
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
// System.out.println(src);
}
}
字符串变量拼接的底层原理
String 类特有的 ‘+’ 重载是根据 StringBulider.append() ,最后的结果再 toString 实现
@Test
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
/*
如下的s1 + s2 的执行细节:(变量s是我临时定义的)
① StringBuilder s = new StringBuilder();
② s.append("a")
③ s.append("b")
④ s.toString() --> 约等于 new String("ab")
补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
*/
String s4 = s1 + s2;//
System.out.println(s3 == s4);//false
}
/*
1. 字符串拼接操作不一定使用的是StringBuilder!
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
*/
@Test
public void test4(){
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);//true
}
//练习:
@Test
public void test5(){
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3);//false
final String s4 = "javaEE";//s4:常量
String s5 = s4 + "hadoop";
System.out.println(s1 == s5);//true
}
intern 的使用
new String() 到底创建了几个对象
- toString 的调用并不会在常量池里生成对象
代码示例:
/**
* 题目:
* new String("ab")会创建几个对象?看字节码,就知道是两个。
* 一个对象是:new关键字在堆空间创建的
* 另一个对象是:字符串常量池中的对象"ab"。 字节码指令:ldc
*
*
* 思考:
* new String("a") + new String("b")呢?
* 对象1:new StringBuilder()
* 对象2: new String("a")
* 对象3: 常量池中的"a"
* 对象4: new String("b")
* 对象5: 常量池中的"b"
*
* 深入剖析: StringBuilder的toString():
* 对象6 :new String("ab")
* 强调一下,toString()的调用,在字符串常量池中,没有生成"ab"
*
* @author shkstart shkstart@126.com
* @create 2020 20:38
*/
public class StringNewTest {
public static void main(String[] args) {
// String str = new String("ab");
String str = new String("a") + new String("b");
}
}
关于 intern 的面试难题
这个问题的难点就在不同版本答案不一样
-
jdk6 里面常量池和堆是分开的,所以 s3.intern() 是在常量池里面创建了一个真实的对象,但是对于jdk7和jdk8 来说,常量池和堆在一起,为了节省内存,所以在常量池里面存的其实是个指向堆的引用,所以s3==s4才会返回 true(在 jdk78中)
-
s3.intern() 只是返回引用,没有给s3 赋值
- 对于程序大量存在的字符串,尤其其中存在很多重复的字符串时,使用 intern() 可以节省内存空间
G1 中 的String 去重操作
- 当前 jdk 中默认的垃圾回收器 G1
- 这里的去重不是指常量池中的,而是指堆里面两个相同的对象(把常量池和堆分开看)
垃圾回收相关章节的说明
- jdk14 CMS垃圾回收器已经不再使用了
- 在未来 ZGC 可能会替换 G1 垃圾回收器
什么是 GC ?为什么要使用 GC?
未完:下一篇