JVM 基础
JVM 基础中的基础
一个进程对应一个JVM实例,对应一个堆栈+方法区
运行时数据区域
P43
程序计数器(PC寄存器 - Program Counter Register)
- 行号指示器、“线程私有”、本地方法计数器为空(undefined)、用来存储指向下一条指令的地址…
- 切换线程需要知道下一次执行之前线程的地址
虚拟机栈
- 每个线程创建时都会创建一个虚拟机栈,内部有一个个的栈帧,线程私有
- 每个方法 - 栈帧(局部变量表(主要)、操作数栈、动态链接、方法出口…)(入栈到出栈)
- 保存方法的局部变量、部分结果,参与方法的调用与返回
- 栈溢出(如递归,会StackOverflowError,而栈如果动态扩容超过内存物理大小,则会 OOM)
- 这里不存在gc
栈的存储单位 - 栈帧(P294)
局部变量表(本地变量表)
- 方法参数和方法的局部变量
- 最小单位是变量槽(Variable Slot),就是个数组
- 对于64位数据类型,JVM会高位对齐,为其分配两个槽(long、double)
this
有在里面,也是个变量- 静态方法中,不能使用 this,因为 this 不会在这个方法的局部变量表,因为 this 代表对象实例,而静态是随类加载而加载,先于实例就有了,所以 this 放进去没卵用
- 变量表大小是编译时就确定了,运行时不能更改
- JVM通过索引定位使用变量表
- 槽是可以重用的,即有些变量是很小的作用域,超过的话这个槽就会被重用
- 因为是线程独占私有的,所以不存在并发问题
- 局部变量不会有默认值/初始值,在使用前必须要显式赋值
- 局部变量表存的对象引用和 GC 密切相关
操作数栈(操作栈)
- 用数组实现,LIFO
- push、store。。。(方法中)
- 保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间
- 栈中的任何一个元素都可以是任意的Java类型
- 32位占用一个栈深度
- 64两个
- 栈的最大深度在编译期就定义好了
- byte、short、char、boolean都以int来保存
- ++i 和 i++ 的区别
- 栈顶缓存(HotSpot):栈顶元素全部缓存在物理CPU的寄存器,提高执行引擎效率
动态链接
- 即指向运行时常量池中该栈帧所属方法的引用(如#6等),动态链接的作用是将这些方法引用转换为直接引用
- (Class 常量池的内容会存放到运行时常量池(如:子类调用父类方法,调用其他类的方法))
- 下面只是说明 Class 常量池
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接
- math.compute()调用时compute()叫符号,需要通过compute()这个符号去到常量池中去找到对应方法的符号引用,运行时将通过符号引用找到方法的字节码指令的内存地址。
方法返回值/返回地址(P300)
栈顶方法返回该方法被调用的位置
- 正常调用返回
- 主调方法的PC计数器的值作为返回地址,保存在栈帧
- 异常返回
- 返回地址通过异常处理器表来确定,栈帧不会保存信息
附加消息
- 其他信息
动态链接+方法返回地址+附加消息 = 帧数据区(或栈帧信息(深入了解JVM))
代码跟踪
静态链接和动态链接
- 静态,编译期就确定的
- 动态:编译期无法确定,如多态的方法调用
方法的调用:解析+分派(P301)
- 方法调用
- invokestatic:调静态方法
- invokespecial:调构造器(<init>())方法
- invokevirtual:调虚方法
- 注意:final方法是非虚方法,但是是用invokevirtual调用(历史设计)
- invokeinterface:调接口方法,会在运行时再确定实现该接口的对象
- invokedynamic:运行时动态解析出所引用的方法,再执行
- invokestatic和invokespecial+final(invokevirtual)是都是静态的,编译期就把符号引用转为直接引用,即非虚,其他为虚方法
- 分派有静态也有动态
- 动态:如多态的重写
OutOfMemoryError
StackOverflowError
- 设置
-Xsssize
:-Xss1m、-Xss1024k、-Xss1048576
本地方法栈
- 与虚拟机栈作用相似
- HotSpot直接把虚拟机栈和本地方法栈合二为一
- OutOfMemoryError
- StackOverflowError
堆
- 内存最大的一块
- 物理内存不连续,逻辑是连续的(虚拟内存(见操作系统))
- 被所有线程共享
- 但是:堆里可以划分线程私有的缓冲区 thread local allocation buffer
- 虚拟机启动时创建
- 这个区域的唯一目的就是存放对象实例
- 也称为 GC 堆
- 分代设计:新生代、老年代、永久代(JDK 8 没了,改用本地内存中实现的元空间)、Eden 空间、From/To Survivor 空间…
- 细分的目的只是为了更好的回收内存,更好的分配内存
- 可扩展:-Xms、-Xmx、-XX
- OOM
年轻代 & 老年代
test
内存结构
7及以前:新生代区域、老年代区域、永久代区域(PSPermGen)
8及以后:新生代区域、老年代区域、元空间(Metaspace)
- Young Gen 新生代区域
- Eden 区
- Survivor 0
- Survivor 1
- Old Gen 老年代区域
- 元空间 Metaspace(可通过 -XX:+PrintGCDetails 显示)
内存分配策略
test
对象分配过程
TLAB(堆里可以划分线程私有的缓冲区 thread local allocation buffer)
test
逃逸分析 & 栈上分配
test
Minor GC & Major GC & Full GC
test
方法区
(元数据区/堆外内存。1.8)(堆外内存也包括JIT编译产物)
- Method Area
- (只有 HotSpot 有
- 被所有线程共享
- 存储已经被虚拟机加载的类型信息、常量、静态变量…
- 是堆的一个逻辑部分,但叫做“非堆(Non-Heap)”
- OutOfMemoryError(如动态类,即 CGLib 那些,可能导致 OOM
- 运行时常量池
- 方法区的一部分
- 常量池表 Constant Pool Table,用于存放编译期生成的字面量和符号引用(字符串,能根据这个字符串定位到指定的数据,比如java/lang/StringBuilder),这部分内容将在类加载后存放到运行时常量池
- 具备动态性:运行期间也可以将新的常量放入池中(String#intern)
- OutOfMemoryError
直接内存
- 不是运行时数据区域的一部分
- OutOfMemoryError
- JDK 1.4 有 NIO,基于通道与缓冲区,使用 Native 函数库直接分配堆外内存,然后通过一个存储在堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了在堆与 Native 堆之间来回复制
- 本机直接内存的分配不会受到限制,但是会受到总内存的限制,也会OOM
执行引擎
等
HotSpot 对象创建 简单版
P48
- 取决于 Java 堆是否规整
- 指针碰撞
- 空闲列表
- 并发下分配内存方式
- CAS
- 每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在自己的本地缓冲区分配,只有缓冲区用完,分配新的缓冲区才需要同步锁定
- 是否使用 TLAB:-XX:+/-UserTLAB
- 内存分配完成后,虚拟机把内存空间初始化为零值,保证实例字段能直接访问到
- 设置对象头信息,如这个对象是哪个类的实例、元数据信息、哈希码、GC 分代年龄、偏向锁…
- 这时候构造函数还没执行,都还是默认零值,如何初始化有程序员决定
HotSpot 对象内存布局
P51
- 对象头(Header)
- 存储对象自身运行时数据(32 位虚拟机为 32 个比特;64 为 64 个比特)
- (Mark Word)
- 类型指针,指向类型元数据,确定该对象是哪个类的实例(找元数据不一定要经过对象本身)
- 如果是数组,还有一块数据是记录数组长度
- 存储对象自身运行时数据(32 位虚拟机为 32 个比特;64 为 64 个比特)
- 实例数据(Instance Data)
- 无论是从父类来的,还是定义的,都在这里记录
- 对齐填充(Padding)
- 仅仅起到占位符的作用
- HotSpot 要求对象起始地址必须为 8 字节的整数倍,所以任何对象的大小都必须是 8 字节的整数倍
对象的定位
通过栈上的 reference 引用来操作堆上的具体对象。
引用的定位实现是由虚拟机决定的,没有一定。
主流有两种
句柄
- 堆中可能要划分出一块内存来作为句柄池,reference 中存的就是对象的句柄地址
- 句柄包含了对象实例数据与类型数据各自具体的地址信息
直接指针
句柄最大的好处就是,对象被移动的时候只会改变句柄中实例数据指针,而 reference 本身不改变
直接指针最大的好处是快,开销少
HotSpot 主要使用直接指针
对于内存泄漏和溢出
分析工具:Eclipse Memory Analyzer
作者:McAce
链接:https://www.zhihu.com/question/40560123/answer/512873873
来源:知乎内存泄露本意是申请的内存空间没有被正确释放,导致后续程序里这块内存被永远占用(不可达),而且指向这块内存空间的指针不再存在时,这块内存也就永远不可达了,内存空间就这么一点点被蚕食,借用别人的比喻就是:比如有10张纸,本来一人一张,画完自己擦了还回去,别人可以继续画,现在有个坏蛋要了纸不擦不还,然后还跑了找不到人了,如此就只剩下9张纸给别人用了,这样的人多起来后,最后大家一张纸都没有了。
内存溢出是指存储的数据超出了指定空间的大小,这时数据就会越界,举例来说,常见的溢出,是指在栈空间里,分配了超过数组长度的数据,导致多出来的数据覆盖了栈空间其他位置的数据,这种情况发生时,可能会导致程序出现各种难排查的异常行为,或是被有心人利用,修改特定位置的变量数据达到溢出攻击的目的。而Java中的内存溢出,一般指【OOM:发生位置】这种Error,它更像是一种内存空间不足时发生的错误,并且也不会导致溢出攻击这种问题,举例来说,堆里能存10个数,分了11个数进去,堆就溢出了1个数,JVM会检测、避免、报告这种问题,所以虽然实际上JVM规避了内存溢出带来的问题,但在概念上来说,它确实是溢出才导致的,只是Java程序员在看到这个问题时,脑袋里的反应会是“内存不够了,咋回事,是不是又是哪个大对象没释放”之类,而不是像C程序员“我X被攻击了/程序咋写的搞溢出了”(这段是我臆想的)。同时对于Java来说,传统意义的溢出攻击也无法奏效,因为Java的数组会检查下标,对超出数组下标的赋值会报ArrayOutOfIndex错误。
而内存泄露的话,个人意见在Java里是不存在的,gc采用根搜索算法时,不可达的对象会被回收,gc是会搜索回收这些空间的,由于程序员个人问题,没用的对象不回收但可达,这种情况能不能界定为内存泄露,我觉得是个哲学问题(对象可达,但空间被占用了,对象也不再使用了),个人觉得是不能界定为内存泄露的。
底下评论:
java 中也会存在内存泄露的,比如在使用 ThreadLocal 这个类时,就易发生内存泄露。
内存泄漏在早期 java 版本中比较多,主要是 hotspot 没对 method area 进行有效回收导致的
字符串常量池
P63
1 | String str1 = new StringBuilder("计算机").append("软件").toString(); |
JDK 6 中,intern 会把首次遇到的字符串实例复制到永久代的字符串常量池,返回的也是永久代里面这个字符串实例的引用,而 StringBuilder 创建的字符串对象实例是在 Java 堆上的,所以不可能是同一个引用
JDK 7 中 intern 不再需要拷贝字符串实例到永久代,字符串常量池已经移到了 Java 堆中。只需要在常量池中记录首次出现的实例引用即可,所以 intern 返回的引用和 StringBuilder 创建的字符串实例就是同一个
而“java”已经出现过,字符串常量池已经有他的引用,所以为 false(之前有进入常量池)
垃圾收集
java的垃圾收集机制主要针对新生代和老年代的内存进行回收,不同的垃圾收集算法针对不同的区域。所以java的垃圾收集算法使用的是分代回收。一般java的对象首先进入新生代的Eden区域,当进行GC的时候会回收新生代的区域,新生代一般采用复制收集算法,将活着的对象复制到survivor区域中,如果survivor区域装在不下,就查看老年代是否有足够的空间装下新生代中的对象,如果能装下就装下,否则老年代就执行FULL GC回收自己,老年代还是装不下,就会抛出OUtOfMemory的异常
判断对象是否存活
- 引用计数
- 有一个地方引用对象,对象的计数器加一,引用失效,计数器减一,计数器为零就是不可能再被使用
- 简单,效率高
- 难以解决对象之间相互循环引用的问题
- 可达性分析
- GC Roots。对象到 GC Roots 不可达时,就会被判定可回收
引用
- 强引用、软引用、弱引用、虚引用
对象死亡
- 一个对象的死亡,至少要经历两次标记(标记指的是到 GC Roots 不可达时标记这个对象)
- 第一次标记完,会有一次筛选,看看对象有没有必要执行 finalize 方法
- 对象没覆盖 finalize 方法/finalize 方法已经被虚拟机调用,就没必要
- 如果有必要,对象放入 F-Queue,虚拟机会去触发对象的 finalize 方法(不会等他)
- 收集器会继续对 F-Queue 中的对象进行第二次标记,如果对象重新与引用链上的对象建立关联,就能活下来
不建议使用 finalize 方法,用 try-finally
分代收集
收集器应该将 Java 堆分为不同区域,将回收对象依据年龄(对象逃过垃圾收集的次数)扔到不同的区域存储
- Minor GC、Major GC、Full GC
- 新生代 Young Generation、老年代 Old Generation
算法
- 标记-清除
- 标记-复制
- 标记-整理
HotSpost 算法实现
-
类加载
双亲委派
P281、283
父子关系非继承,而是组合的方式来复用父加载器的代码
一个类加载器收到类加载的请求,首先不会自己尝试加载,而是把这个请求委派给父加载器去加载,因此所有的加载都会被委派到最顶层的启动类加载器。
只有上层的无法加载,下面的小弟才会尝试加载
不然的话,要是用户自己写了个 Object 类,放到 ClassPath,就会有不同的加载器加载了不同的 Object 类
实现双亲委派的代码在 java.lang.ClassLoader 的 loadClass 方法中
类加载器
启动(引导)类加载器、扩展类加载器、应用程序加载器(也叫系统类加载器)
图同上 ↑
P282
启动类加载器是无法被引用的,null
对于自定义加载器
- 作用主要有
- 添加除磁盘外的 Class 来源
- 隔离、重载(防止不同中间件的冲突)
- 防止源码泄露
- 继承 ClassLoader,重写 findClass
沙箱安全机制
保护源码的安全性
- 自定义类如 String 类,加载时会率先使用引导类加载器,而不是自定义类的应用程序加载器
JVM 中对象是否是同一个:
- 完整类名
- 类加载器
类加载机制
加载 - 连接(验证-准备-解析)- 初始化
加载
(字节流
拿到类的类型数据(包括使用的类加载器(启动类加载器无法引用,为 null)),放到方法区(8 就是元空间了),然后在堆实例化一个 Class 对象,作为程序访问方法区的类型数据的入口
连接
验证
要保证字节码文件的正确性、安全性等
验证包括 文件格式、元数据、字节码、符号引用
准备
为类中定义的静态变量分配内存还有设置初始值
7及以前,是方法区,而在8,类变量会随类对象一起到堆中
(这里不包括实例值,还只是初始值)
但要是静态变量还加了个 final,那就有赋值
解析
Class 文件的常量池的符号引用转化为直接引用
初始化
执行 类构造器方法 <clinit>()
要是没有静态语句块,也就没有这个
并且在静态中,是按顺序赋值的