架构师每天思考的内容
- 如何让我的系统更快
- 如何避免系统出现瓶颈
Java & JVM
- Java: 跨平台的语言 "write once, run anywhere", 字节码文件在不同操作系统的JVM上运行
- JVM : 跨语言的平台 Kotlin, Clojure, Groovy, Scala, Jython, JavaScript编译为字节码文件在JVM中运行
- Java不是最强大的语言, 但是JVM是最强大的虚拟机
JVM(Java虚拟机)
- 作用: 二进制字节码的运行环境, 负责装载字节码到其内部, 解释/编译为对应平台上的机器指令执行
- 特点: 一次编译, 到处运行; 自动内存管理和自动垃圾回收
- 位置: 在操作系统之上, 与硬件没有直接交互
- 整体结构
- 类装载子系统
- 运行时数据区: 堆, 方法区, Java栈, 本地方法栈, 程序计数器
- 执行引擎: 解释器, JIT编译器, 垃圾回收
- 本地方法接口/本机方法库
- 架构模型: 基于栈式架构
- 由于跨平台性的设计, Java的指令都是根据栈来设计的. 不同平台CPU架构不同, 所以不能设计为基于寄存器的
- 优点: 跨平台, 指令集小, 编译器容易实现
- 缺点: 性能下降(实现同样的功能需要更多的指令)
- 生命周期
- 启动: 通过引导类加载器创建一个初始类来完成的
- 执行: 执行Java程序
- 退出:
- 程序正常执行结束
- 程序执行过程中遇到异常或错误而终止
- 操作系统错误导致Java虚拟机进程终止
- 调用Runtime或System的exit方法
- JVM的实现
- Sun Classic VM: jdk1.0时提供, 只提供解释器(没有JIT编译器)
- Exact VM: jdk1.2时提供, 准确式内存管理(知道内存中某个位置数据的具体类型), 具备现代高性能虚拟机雏形
- HotSpot VM: jdk1.3时提供, 占据绝对的市场地位, 称霸武林
- 通过计数器找到最具编译价值代码, 触发即时编译或栈上替换
- 通过编译器与解释器协同工作, 在最优化的程序响应时间与最佳执行性能中取得平衡
- BEA的JRokcet
- 专用于服务器端应用: 不太关注程序的启动速度, 内部不包含解释器, 全部代码靠即时编译器编译后执行
- JMC套件(Java Misson Control): 极低的开销监控,管理和分析生产环境中的应用程序工具
- 2008年, BEA被Oracle收购, 大致在jdk8中将两大优秀虚拟机整合完成
- IBM的J9
- 2017年发布了开源的J9VM, 命名为OpenJ9, 交给Eclipse基金会管理
- KVM: JavaME产品线上, 简单轻量, 面向更低端的设备
- AzulVM: 与特定硬件平台绑定, 软硬件配合的专有虚拟机(超高性能)
- LiquidVM: 不需要操作系统支持, 直接运行在BEA公司自家的Hypervisor系统上
- Apache Harmony
- MicrosoftJVM
- TaobaoJVM
- 创建的GCIH(GC invisible heap)技术实现了off-heap: 即将生命周期较长的Java对象从heap中移到heap外, 并且GC不能管理GCIH内部的Java对象, 以此达到降低GC的回收频率和提升GC的回收效率目的
- GCIH中的对象还能够在多个Java虚拟机进程中共享
- DalvikVM:
- 谷歌开发的, 应用于安卓系统, 只能称为虚拟机, 不能称为Java虚拟机(因为没有遵循Java虚拟机规范)
- 基于寄存器架构
- 不能执行Java的class文件, 而是执行编译后的dex文件
- GraalVM: High-performance polyglot VM
- 高性能优化编译器: 优化的JIT编译器
- 本机镜像(Native Image): AOT编译器
- 多语言支持
类加载子系统
-
作用: 负责从文件系统或网络中加载class文件, 加载的类信息存放于方法区
-
来源(.class文件)
- 编译后的class文件
- 网络中获取的class文件, 典型场景: Web Applet
- 从zip包中读取: jar, war格式的基础
- 运行时计算生成, 使用最多的是动态代理技术
- 有其他文件生成, 典型场景: JSP应用
- 从加密文件中获取, 典型的防Class文件被反编译的保护措施
-
阶段
- 加载阶段: 双亲委派机制(避免类的重复加载 + 防止核心API被随意篡改)
- 引导类加载器: 加载java核心库(JAVA_HOME/jre/lib/rt.jar, resources.jar等), 出于安全考虑只加载java, javax, sun开头的类
- 扩展类加载器: 加载jre/lib/ext下的类库, 如果用户创建的jar放在此目录也会加载
- 系统类加载器: 也称为应用程序类加载器, 加载classpath下的类库
- 用户自定义类加载器: 继承抽象类ClassLoader, 重写loadClass方法
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
- 链接阶段: 验证, 准备, 解析
- 验证: 文件格式, 元数据, 字节码, 符号引用
- 准备: 为类变量分配内存并设置默认初始值(零值)
- 解析: 将常量池中的符号引用转换为直接引用, 主要针对类或接口, 字段, 类方法, 接口方法, 方法类型等
- 初始化阶段: 执行类构造器方法
, 是javac编译器自动收集的类变量赋值和静态代码块语句
- 加载阶段: 双亲委派机制(避免类的重复加载 + 防止核心API被随意篡改)
-
获取ClassLoader的途径
- 方式1: 获取当前类的ClassLoader: clazz.getClassLoader()
- 方式2: 获取当前线程的ClassLoader: Thread.currentThread().getContextClassLoader()
- 方式3: 获取系统的ClassLoader: ClassLoader.getSystemClassLoader()
- 方式4: 获取调用者的ClassLoader: DriverManager.getCallerClassLoader()
-
JVM中两个class对象是否为同一个类的两个必要条件
- 类的全限定类名必须一致
- 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
-
Java程序对类的使用分为: 主动使用和被动使用
- 主动使用
- 创建类的实例(new)
- 访问类的静态变量, 包括读取和更新
- 调用类的静态方法
- 对某个类进行反射操作(class.forName)
- 初始化一个类的子类会导致父类的初始化
- 执行该类的main函数(Java虚拟机启动时被标明为启动类的类)
- jdk7开始提供的动态语言支持: java.lang.invoke.MethodHandle实例的解析结果. REF_getStatic, REF_putStatic, REF_invokeStatic句柄对应的类没有初始化, 则初始化
- 除了以上7种情况, 其他使用Java类的方式对类的被动使用, 都不会导致类的初始化
- 引用该类的静态字面量常量不会导致初始化
比如: public final static int number = 5
- 构造某个类的数组不会导致该类的初始化
比如: Student[] stus = new Student[10];
- 通过子类引用父类的静态字段, 为子类的被动使用, 不会导致子类的初始化
- 引用该类的静态字面量常量不会导致初始化
- 主动使用
运行时数据区(Runtime)
-
程序计数器: (PC寄存器/PC计数器/指令计数器)线程私有, 无GC, 无OOM
- 用来存储指向下一条指令的地址
- 是程序控制流的指示器, 分支/循环/跳转/异常处理/线程恢复等基础功能都依赖这个计数器完成
使用PC寄存器存储字节码指令地址有什么用?
因为CPU需要不停的切换各个线程, 当切换回来以后, 就得知道接着从哪开始继续执行
PC寄存器为什么会被设定为线程私有?
也是因为CPU需要不停的切换各个线程, 为了能够准确记录各个线程正在执行的当前字节码指令地址, 最好的办法就是为每一个线程分配一个PC寄存器 -
虚拟机栈: 线程私有, 无GC, 有OOM, 有栈溢出
- 栈与堆: 栈是运行时的单位, 堆是存储的单位
- 是什么: 每个线程在创建时都会创建一个虚拟机栈, 其内部保存一个个的栈帧, 对应着一次次的方法调用
- 生命周期: 与线程一致
- 作用: 主管Java程序的运行, 它保存方法的局部变量,部分结果并参与方法的调用和返回
- 栈中存储什么: 栈帧(Stack Frame)
- 栈帧的内部结构:
- 局部变量表(Local Variables)
- 定义为一个数字数组, 主要用于存储方法参数和定义在方法体内的局部变量
- 通过索引来访问
- 所需容量大小是在编译期间就确定的
- 最基本的存储单元是Slot(槽): 32位以内的类型只占据一个slot, 64位类型(long和double)占用两个槽
- 局部变量表中的变量也是重要的垃圾回收根节点, 只要被局部变量表中直接或间接引用的对象都不会被回收
- 类变量与局部变量的对比
类变量有两次初始化的机会,第一次是"准备"阶段设置零值, 另一次是在"初始化"阶段,设置代码中定义的初始值
和类变量初始化不同的是, 局部变量不存在系统初始化的过程, 意味着一旦定义了局部变量则必须人为的初始化, 否则编译不通过
- 操作数栈(Operand Stack) (或表达式栈)
- 方法执行过程中, 根据字节码指令往栈中写入数据或提取数据即入栈(push)和出栈(pop)
- 主要用于保存计算的中间结果, 同时作为计算过程中变量临时的存储空间
- 编译期就确定了明确的栈深度, 32bit占用一个栈深度, 64bit占用二个栈深度
- 操作数栈并非采用索引方式进行数据访问, 而是只能通过标准的入栈/出栈来完成数据访问
- 如果被调用的方法带有返回值的话, 其返回值将会被压入当前栈帧的操作数栈中, 并更新PC寄存器中下一条需要执行的字节码指令
- 我们说Java虚拟机的解释引擎是基于栈的执行引擎, 其中的栈指的就是操作数栈
- 栈顶缓存技术: 将栈顶元素全部缓存在物理CPU的寄存器中, 以此降低对内存的读/写次数, 提升执行引擎的执行效率
- 动态链接(Dynamic Linking) (或指向运行时常量池的方法引用)
- 为了将编译到字节码中的符号引用转换为调用方法的直接引用
- 方法的调用
- 静态链接(早期绑定): 目标方法在编译器可知, 非虚方法(静态方法, 私有方法, final方法, 实例构造器, 父类方法)
- 动态链接(晚期绑定): 被调用的方法在编译器无法被确定下来, 虚方法
- 虚方法表: 在类加载的链接阶段初始化完毕
- 方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
- 存放调用该方法的PC寄存器的值
- 一些附加信息
- 例如对程序调试提供支持的信息
- 局部变量表(Local Variables)
- 栈中可能出现的异常
- JVM规范允许Java栈的大小是动态的或者固定不变的
- 固定大小: StackOverflowError
- 动态扩展: OutOfMemoryError
- 设置栈内存大小: -Xss设置线程的最大栈空间, 栈的大小直接决定了函数调用的最大可达深度
- Linux/Mac/Solaric: 默认为1M
- Windows: 依赖虚拟内存
- JVM规范允许Java栈的大小是动态的或者固定不变的
- 栈的面试题
- 举例栈溢出的情况
- 调整栈大小, 就能保证不出现溢出吗?
- 分配的栈内存越大越好吗?
- 垃圾回收是否会涉及虚拟机栈?
- 方法中定义的局部变量是否线程安全?
-
本地方法栈: 线程私有
- 本地方法: Java调用非Java代码的接口, 初衷是融合C/C++程序
- 为什么要使用本地方法?
- 与Java环境外交互
- 与操作系统交互
- Java虚拟机栈管理Java方法的调用, 而本地方法栈用于管理本地方法的调用
- 当某个线程调用一个本地方法时, 就进入了一个全新的并且不再受虚拟机限制的世界. 它和虚拟机拥有同样的权限
-
堆: 线程共享
- 核心概述
- 一个JVM实例只存在一个堆内存, 堆也是Java内存管理的核心区域
- Java堆在JVM启动时被创建, 是JVM管理的最大一块内存空间
- 所有线程共享Java堆, 在这里还可以划分线程私有的缓冲区(TLAB)
- "几乎"所有的对象实例以及数组都在运行时分配在堆上
- 方法结束后堆中的对象不会马上被移除, 仅在垃圾收集时才会被移除
- 堆, 是GC执行垃圾回收的重点区域
- 内存细分: 现代垃圾收集器大部分基于分代收集理论设计
- 新生代 Young/New: Eden区和Survivor区(S0/S1)
- 老年代 Old/Tenure
- 永久区 Perm (jdk8改为元空间 Meta)
- 常见参数设置
- -Xms设置初始堆空间, 等价于 -XX:InitialHeapSize, 默认为物理内存的1/64
- -Xmx设置最大堆空间, 等价于 -XX:MaxHeapSize, 默认为物理内存的1/4
通常将-Xms和-Xmx两个参数配置相同的值, 目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小, 从而提高性能
- -XX:+PrintGCDetails 打印GC详细信息
- -XX:+PrintFlagsInitial: 查看所有参数的默认初始值
- -XX:+PrintFlagsFinal: 查看所有参数的最终值
- -XX:NewRatio=2 默认新生代和老年代的占比为1:2
- -XX:SurvivorRatio=8 默认Eden空和S0/S1的空间占比为8:1:1
- -XX:+UseAdaptiveSizePolicy 默认打开自适应的内存分配策略
- -XX:MaxTenuringThreshold=15 默认15次垃圾回收后仍存活的对象到老年代
- -XX:+UseTLAB 默认开启TLAB
- -XX:TLABWasteTargetPercent 默认TLAB空间很小仅占Eden空间的1%
- -XX:MinHeapFreeRatio 最小堆空闲百分比, 默认40%
- -XX:MaxHeapFreeRatio 最大堆空闲百分比, 默认70%
- 对象分配过程
新对象申请时, 先看Eden区是否放的下, 放不下则执行YGC, 执行完毕后再看Eden是否放得下, 放不下(即超大对象)则看Old区是否放得下, 放不下执行FGC, 执行完毕后再看是否放得下, 放不下OOM
- 垃圾的部分收集和整堆收集
- Minor GC(Young GC): 只是新生代的垃圾收集
- Major GC(Old GC): 只是老年代的垃圾收集(目前只有CMS GC会有单独收集老年代的行为)
- Mixed GC: 收集整个新生代以及部分老年代的垃圾收集(目前只有G1 GC有这种行为)
- Full GC: 收集整个Java堆和方法区的垃圾收集
- 垃圾回收的触发条件
- Minor GC的触发条件: Eden区空间不足
- Major GC/Full GC的触发条件: Old区空间不足
- 一般Major GC速度比Minor GC慢10倍以上
- 出现Major GC经常会伴随至少1次的Minor GC
- Full GC的触发条件
- 调用System.gc(): 系统建议执行Full GC, 但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden/S0向S1复制时, 对象大小大于S1可用内存, 则需要把该对象转到老年代, 且老年代可用内存小于该对象大小
- 为什么有TLAB(Thread Local Allocation Buffer)
堆区是线程共享区域, 为避免多个线程操作同一地址, 需要使用加锁机制, 进而影响分配速度.
从内存模型而不是垃圾收集的角度, 对Eden区继续进行划分, JVM为每个线程分配了一个私有缓存区域.
多线程同时分配内存时, 使用TLAB可以避免一系列的非线程安全问题, 同时能够提升内存分配的吞吐量, 称为快速分配策略 - 空间分配担保
在发生Minor GC之前, 虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间.
如果大于则此次Minor GC是安全的. 如果小于, 则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败. 如果为true, 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小. 如果大于则尝试进行一次Minor GC(但这次Minor GC依然是有风险的). 如果小于则改为进行一次Full GC. 如果为false则改为进行一次Full GC - 堆是分配对象存储的唯一选择吗
随着JIT编译器的发展与逃逸分析技术逐渐成功, 栈上分配与标量替换优化技术将导致一些微妙的变化. 所有的对象都分配到堆上也变得不那么"绝对"了. 如果经过逃逸分析(Escape Analysis)后发现, 一个对象并没有逃逸出方法的话, 那么就可能被优化为栈上分配.
- 逃逸分析: 编译器可以对代码做优化 ==> 开发中能用局部变量的就不要再方法外定义
- 栈上分配
- 同步省略
- 分离对象或标量替换
- 核心概述
-
方法区: (永久代或元空间,代码缓存)线程共享
- 实现: jdk7及以前为永久代, jdk8改为元空间, 原因如下
- 为永久代设置空间大小是很难确定的
- 对永久代进行调优是很困难的
- 永久代与元空间的最大区别: 元空间不在虚拟机设置的内存中, 而使用本地内存
- 方法区的大小
- -XX:Permsize 设置永久代初始大小, 默认为20.75M
- -XX:MaxPermSize 设置永久代最大空间, 32位机器默认为64M, 64位机器默认为82M
- -XX:MetaspaceSize 设置元空间初始大小, 默认为21M
- -XX:MaxMetaspaceSize 默认为-1, 即没有限制
- 方法区存储内容: 类型信息, 运行时常量池, 静态变量, 即时编译器编译后的代码缓存等
- 方法区的垃圾搜集主要回收两部分内容: 常量池中废弃的常量和不再使用的类型
- 实现: jdk7及以前为永久代, jdk8改为元空间, 原因如下
JVM的场景面试题
- 说一下JVM的内存模型, 有哪些区? 分别干什么的?
- Java8的内存分代改进
- 栈和堆的区别? 堆的结构? 为什么两个Survivor区?
- Eden和Survior的比例
- jvm内存分区, 为什么要有新生代和老年代?
- 什么时候对象会进入老年代?
- jvm的永久代会发生垃圾回收吗?
- 对象在JVM中是怎么存储的?
- 对象头信息里面有哪些东西?
对象的实例化,内存布局与访问定位
-
对象的实例化
- 创建对象的方式
- new
- 最常见的new Xxx()
- 变形1: Xxx的静态方法
- 变形2: XxxBuilder/XxxFactory的静态方法
- Class的newInstance(): 反射的方式, 只能调用空参构造器, 权限必须为public
- Constructor的newInstance(Xxx): 反射的方式, 可以调用空参/带参构造器, 权限没有要求
- 使用clone: 当前类需要实现Cloneable接口的clone()方法
- 使用反序列化: 从文件/网络中获取一个对象的二进制流
- 第三方库Objenesis
- new
- 创建对象的步骤
- 判断对象对应的类是否加载/链接/初始化
- 为对象分配内存
- 内存规整: 指针碰撞
- 内存不规整: 虚拟机需要维护一个列表, 空闲列表分配
- 处理并发安全问题
- 每个线程预先分配一块TLAB
- 采用CAS失败重试, 区域加锁保证更新的原子性
- 属性的默认初始化(零值初始化), 保证对象实例字段在不赋值时可以直接使用
- 设置对象的对象头信息
- 执行init方法进行初始化: 显示初始化, 代码块中初始化, 构造器中初始化
- 创建对象的方式
-
对象的内存布局
- 对象头:
- 运行时元数据(Mark Word): 哈希值, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID, 偏向时间戳
- 类型指针: 指向类元数据, 确定该对象所属的类型
- 说明: 如果是数组, 还需记录数组的长度
- 实例数据: 对象真正存储的有效信息, 包括程序代码中定义的各种类型字段(包括从父类继承下来的和本身拥有的字段)
- 对齐填充: 不是必须的, 也没特别含义, 仅仅起到占位符的作用
- 对象头:
-
对象的访问定位
- JVM如何通过栈帧中的对象引用访问到其内部的对象实例呢? 通过栈上reference访问
reference 栈帧 --> instanceOopDesc 堆区 --> InstanceKlass 方法区
- 对象访问方式
- 句柄访问: 好处为reference中存储稳定句柄地址, 对象被移动时只会改变句柄中实例数据指针即可
- 直接指针(Hotspot采用): 好处更快捷
- JVM如何通过栈帧中的对象引用访问到其内部的对象实例呢? 通过栈上reference访问
直接内存
- 概述
- 不是运行时数据区的一部分, 也不是虚拟机规范中定义的内存区域
- 直接内存是Java堆外的, 直接向系统申请的内存区间
- 来源于NIO, 通过存在堆中的DirectByteBuffer操作Native内存
- 通常, 访问直接内存的速度会优于Java堆, 即读写性能高