JVM
说一下 JVM 的主要组成部分及其作用?
JVM 包含两个子系统和两个组件,两个子系统为 Class loader(类装载)、 Execution engine(执行引擎); 两个组件为 Runtime data area(运行时数据 区)、Native Interface(本地接口)。
- Class loader(类装载):根据给定的全限定名类名(如: java.lang.Object)来装载 class 文件到 Runtime data area 中的 method area。类装载子系统
- Execution engine(执行引擎):执行 classes 中的指令。即时编译,垃圾回收器
- Native Interface(本地接口):与 native libraries 交互,是其它编程语 言交互的接口。===本地方法库
- Runtime data area(运行时数据区域):这就是我们常说的 JVM 的内 存。方法区,堆,虚拟机栈,本地方法栈,程序计数器
作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader) 再把字节码加载到内存 中,将其放在运行时数据区(Runtime data area)的方 法区内,而字节码文件只是 JVM 的一套指令集 规范,并不能直接交给底层操作 系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将 字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他 语言的本 地库接口(Native Interface)来实现整个程序的功能。
下面是 Java 程序运行机制详细说明
Java 程序运行机制步骤
首先利用 IDE 集成开发工具编写 Java 源代码,源文件的后缀为.java;
再利用编译器(javac 命令)将源代码编译成字节码文件,字节码文件的后缀名 为.class;
运行字节码的工作是由解释器(java 命令)来完成的。
从上图可以看,java 文件通过编译器变成了.class 文件,接下来类加载器又将这 些.class 文件加载到 JVM 中。
其实可以一句话来解释:类的加载指的是将类的.class 文件中的二进制数据读入 到内存中,将其放在运 行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结 构
说一下 JVM 运行时数据区
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个 不同的数据区域。这些区 域都有各自的用途,以及创建和销毁的时间,有些区域 随着虚拟机进程的启动而存在,有些区域则是依 赖线程的启动和结束而建立和销 毁。Java 虚拟机所管理的内存被划分为如下几个区域:
不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的 区域分为以下 5 个部分:
- 程序计数器(Program Counter Register):当前线程所执行的字节码的行号 指示器,字节码解 析器的工作是通过改变这个计数器的值,来选取下一条需要执行的 字节码指令,分支、循环、跳 转、异常处理、线程恢复等基础功能,都需要依赖这个 计数器来完成;
- Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作 数栈、动态链接、方 法出口等信息;
- 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚 拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;
- Java 堆(Java Heap):Java 虚拟机中内存大的一块,是被所有线程共享 的,几乎所有的对象实例 都在这里分配内存;
- 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变 量、即时编译后的代 码等数据。
深拷贝和浅拷贝
浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,
深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加 的指针指向这个新的内 存,
使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的 错误。 浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来 的对象也会相应的改 变。 深复制:在计算机中开辟一块新的内存地址用于存放复制的对象。
说一下堆栈的区别?
物理地址
堆的物理地址分配对对象是不连续的。因此性能慢些。在 GC 的时候也要考虑到 不连续的分配,所以有 各种算法。比如,标记-消除,复制,标记-压缩,分代 (即新生代使用复制算法,老年代使用标记—— 压缩) 栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性 能快
内存分别
堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般 堆大小远远大于栈。
栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。
存放的内容
堆存放的是对象的实例和数组。因此该区更关注的是数据的存储
栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。 PS:
- 静态变量放在方法区
- 静态的对象还是放在堆。
程序的可见度
堆对于整个应用程序都是共享、可见的。
栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同
队列和栈是什么?有什么区别?
队列和栈都是被用来预存储数据的。
- 操作的名称不同。队列的插入称为入队,队列的删除称为出队。栈的插入称为进 栈,栈的删除称为 出栈。
- 可操作的方式不同。队列是在队尾入队,队头出队,即两边都可操作。而栈的进 栈和出栈都是在栈 顶进行的,无法对栈底直接进行操作。
- 操作的方法不同。队列是先进先出(FIFO),即队列的修改是依先进先出的原 则进行的。新来的 成员总是加入队尾(不能从中间插入),每次离开的成员总是队列 头上(不允许中途离队)。而栈 为后进先出(LIFO),即每次删除(出栈)的总是当 前栈中新的元素,即后插入(进栈)的元素, 而先插入的被放在栈的底部,要到后才能删除。
对象的创建
说到对象的创建,首先让我们看看 Java 中提供的几种对象创建方式:
- 使用 new 关键字
- 使用 Class 的 newInstance 方法
- 使用 Constructor 类的 newInstance 方法
- 使用 clone 方法
- 使用反序列化
分配内存,并发处理,对象设置,初始化,方法
虚拟机遇到一条 new 指令时,先检查常量池是否已经加载相应的类,如果没有, 必须先执行相应的类加 载。类加载通过后,接下来分配内存。若 Java 堆中内存是 绝对规整的,使用“指针碰撞“方式分配内存; 如果不是规整的,就从空闲列表 中分配,叫做”空闲列表“方式。划分内存时还需要考虑一个问题-并发, 也有 两种方式: CAS 同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。然后内 存空间初始化操作,接着是做一些必要的对象设置(元信 息、哈希码…),后执行方法。
为对象分配内存
类加载完成后,接着会在 Java 堆中划分一块内存分配给对象。内存分配根据 Java 堆是否规整,有两种方 式:
- 指针碰撞:如果 Java 堆的内存是规整,即所有用过的内存放在一边,而空闲的的 放在另一边。分配 内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小 相等的距离,这样便完成分配内 存工作。
- 空闲列表:如果 Java 堆的内存不是规整的,则需要由虚拟机维护一个列表来记录 那些内存是可用 的,这样在分配的时候可以从列表中查询到足够大的内存分配给对 象,并在分配后更新列表记录。
选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所 采用的垃圾收集器是否带 有压缩整理功能决定。
处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的 位置,在并发情况下也 是不安全的,可能出现正在给对象 A 分配内存,指针还 没来得及修改,对象 B 又同时使用了原来的指针 来分配内存的情况。解决这个 问题有两种方案:
对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的 原子性);
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆 中预先分配一小块内 存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪 个线程的 TLAB 上分配。只有 TLAB 用完并 分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB 参 数来设定虚拟机是否使 用 TLAB。
对象的访问定位
Java 程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决 于 JVM 虚拟机的实现。
目前主流的访问方式有 句柄 和 直接指针 两种方式。
- 指针: 指向对象,代表一个对象在内存中的起始地址。
- 句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是 指向对象的指针 (句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的 真实内存地址。
句柄访问
Java 堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中 包含了对象实例数据与 对象类型数据各自的具体地址信息,具体构造如下图所 示:
优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是 非常普遍的行为)时只 会改变句柄中的实例数据指针,而引用本身不需要修改。
直接指针
如果使用直接指针访问,引用 中存储的直接就是对象地址,那么 Java 堆对象内 部的布局中就必须考虑如 何放置访问类型数据的相关信息
优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在 Java 中非 常频繁,因此这类开销 积少成多后也是非常可观的执行成本。HotSpot 中采用 的就是这种方式。
内存溢出异常 Java 会存在内存泄漏吗?请简单描述
内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说, Java 是有 GC 垃圾回收机制 的,也就是说,不再被使用的对象,会被 GC 自动回收 掉,自动从内存中清除。
但是, 即使这样,Java 也还是存在着内存泄漏的情况,java 导致内存泄露的原因 很明确:长生命周期的 对象持有短生命周期对象的引用就很可能发生内存泄露, 尽管短生命周期对象已经不再需要,但是因为 长生命周期对象持有它的引用而导 致不能被回收,这就是 java 中内存泄露的发生场景。
垃圾收集器
简述 Java 垃圾回收机制
在 java 中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行 执行。在 JVM 中,有一 个垃圾回收线程,它是低优先级的,在正常情况下是不会 执行的,只有在虚拟机空闲或者当前堆内存不 足时,才会触发执行,扫面那些没 有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
GC 是什么?为什么要 GC
GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问 题的地方,忘记或者错误 的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测 对象是否超过作用域从而 达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。
垃圾回收的优点和原理。并考虑 2 种回收机制
java 语言最显著的特点就是引入了垃圾回收机制,它使 java 程序员在编写程序时 不再考虑内存管理的问 题。
由于有这个垃圾回收机制,java 中的对象不再有“作用域”的概念,只有引用的 对象才有“作用域”。
垃圾回收机制有效的防止了内存泄露,可以有效的使用可使用的内存。
垃圾回收器通常作为一个单独的低级别的线程运行,在不可预知的情况下对内存 堆中已经死亡的或很长 时间没有用过的对象进行清除和回收。
程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收。 垃圾回收有分代复制垃圾回 收、标记垃圾回收、增量垃圾回收。
垃圾回收器的基本原理是什么?垃圾回收器可以马上回收 内存吗?有什么办法主动通知虚拟机进行垃圾回收?
对于 GC 来说,当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及 使用情况。
通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式 确定哪些对象是"可达 的",哪些对象是"不可达的"。当 GC 确定一些对象为"不可 达"时,GC 就有责任回收这些内存空间。
可以。程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不 保证 GC 一定会执行。
Java 中都有哪些引用类型?
- 强引用:发生 gc 的时候不会被回收。
- 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
- 弱引用:有用但不是必须的对象,在下一次 GC 时会被回收。
- 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。
怎么判断对象是否可以被回收?
垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收 的,哪些对象是「存活」 的,是不可以被回收的;哪些对象已经「死掉」了,需 要被回收。
一般有两种方法来判断:
- 引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用 被释放时计数 -1, 当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用 的问题;
- 可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。 当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。
在 Java 中,对象什么时候可以被垃圾回收
当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被 回收了。
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全 垃圾回收(Full GC)。如 果你仔细查看垃圾收集器的输出信息,就会发现永久代 也是被回收的。这就是为什么正确的永久代大小 对避免 Full GC 是非常重要的原 因。
JVM 中的永久代中会发生垃圾回收吗**
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全 垃圾回收(Full GC)。如 果你仔细查看垃圾收集器的输出信息,就会发现永久代 也是被回收的。这就是为什么正确的永久代大小 对避免 Full GC 是非常重要的原 因。请参考下 Java8:从永久代到元数据区 (译者注:Java8 中已经移除了永久代,新加了一个叫做元数据区的 native 内存 区)
说一下 JVM 有哪些垃圾回收算法?
- 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清 除垃圾碎片。
- 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的 对象复制到另一块 上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不 高,只有原来的一半。
- 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清 除掉端边界以外的 内存。
- 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年 代,新生代基本采用 复制算法,老年代采用标记整理算法。
标记-清除算法**
标记无用对象,然后进行清除回收。
标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收 集分为两个阶段:
- 标记阶段:标记出可以回收的对象。
- 清除阶段:回收被标记的对象所占用的空间。
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法 的基础上进行改进的。
- 优点**:实现简单,不需要对象进行移动。
- 缺点**:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的 频率。
复制算法**
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划 为两个相等的区域,每次 只使用其中一个区域。垃圾收集时,遍历当前使用的区 域,把存活对象复制到另外一个区域中,最后将 当前使用的区域的可回收的对象 进行回收。
优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。 缺点:可用的内存大小缩小为 原来的一半,对象存活率高时会频繁进行复制。 复制算法的执行过程如下图所示
标记-整理算法**
在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年 代的对象存活率会较 高,这样会有较多的复制操作,导致效率变低。标记-清除 算法可以应用在老年代中,但是它效率不高, 在内存回收后容易产生大量内存碎 片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标 记-整理 算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使 他们紧凑的排 列在一起,然后对端边界以外的内存进行回收。回收后,已用和未 用的内存都各自一边。
- 优点:解决了标记-清理算法存在的内存碎片问题。
- 缺点:仍需要进行局部对象移动,一定程度上降低了效率。
分代收集算法
当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根 据对象的存活周期将内 存划分为几块。一般包括年轻代、老年代 和 永久代,如 图所示:
说一下 JVM 有哪些垃圾回收器?
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体 实现。下图展示了 7 种 作用于不同分代的收集器,其中用于回收新生代的收集器 包括 Serial、PraNew、Parallel Scavenge,回 收老年代的收集器包括 Serial Old、Parallel Old、CMS,还有用于回收整个 Java 堆的 G1 收集器。不同收 集器 之间的连线表示它们可以搭配使用。
- Serial 收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点 是简单高效;
- ParNew 收集器 (复制算法): 新生代收并行集器,实际上是 Serial 收集器的多线程 版本,在多核 CPU 环境下有着比 Serial 更好的表现;
- Parallel Scavenge 收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效 利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC 线程时间),高吞吐量可以高 效率的利用 CPU 时间,尽快完成程 序的运算任务,适合后台应用等对交互相应要求不 高的场景;
- Serial Old 收集器 (标记-整理算法): 老年代单线程收集器,Serial 收集器的老年 代版本;
- Parallel Old 收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先, Parallel Scavenge 收集器 的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集 器,以获取最短回收 停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最 短 GC 回收停顿时间。
- G1(Garbage First)收集器 (标记-整理算法): Java 堆并行收集器,G1 收集器是 JDK1.7 提供的一个新 收集器,G1 收集器基于“标记-整理”算法实现,也就是说不会 产生内存碎片。此外,G1 收集器不同 于之前的收集器的一个重要特点是:G1 回收的范围是整个 Java 堆(包括新生代,老年代),而前六种 收集器回收的范围仅限于新生代 或老年代。
详细介绍一下 CMS 垃圾回收器?
CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得 最短回收停顿时间的垃圾 回收器。对于要求服务器响应速度的应用上,这种垃圾 回收器非常适合。在启动 JVM 的参数加上“- XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。 CMS 使用的是标记-清除的算法实现的, 所以在 gc 的时候回产生大量的内存碎 片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会 被降 低
新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么 区别?
- 新生代回收器:Serial、ParNew、Parallel Scavenge
- 老年代回收器:Serial Old、Parallel Old、CMS
- 整堆回收器:G1
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内 存利用率低;老年代回 收器一般采用的是标记-整理的算法进行垃圾回收。
简述分代垃圾回收器是怎么工作的?
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占 比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年 龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的 执行算法。以上这些循 环往复就构成了整个分代垃圾回收的整体执行流程。
内存分配策略
简述 java 内存分配与回收策率以及 Minor GC 和 Major GC
所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我 们介绍了内存回收,这 里我们再来聊聊内存分配。
对象的内存分配通常是在 Java 堆上分配(随着虚拟机优化技术的诞生,某些场 景下也会在栈上分配, 后面会详细介绍),对象主要分配在新生代的 Eden 区, 如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数情况下也会直 接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取 决于哪一种 垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵 循以下几种 「普世」规则:
- 对象优先在 Eden 区分配
多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行 分配时,虚拟机将会发 起一次 Minor GC。如果本次 GC 后还是没有足够的空 间,则将启用分配担保机制在老年代中分配内存。 这里我们提到 Minor GC,如果你仔细观察过 GC 日常,通常我们还能从日志中 发现 Major GC/Full GC。
Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所 有 Minor GC 非常频 繁,一般回收速度也非常快
Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴 随至少一次 Minor GC。 Major GC 的速度通常会比 Minor GC 慢 10 倍以上。
大对象直接进入老年代
所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导 致在内存还有不少空间 的情况下提前触发 GC 以获取足够的连续空间来安置新对 象。
前面我们介绍过新生代使用的是标记-清除算法来处理垃圾回收的,如果大对象 直接在新生代分配就会导 致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。
长期存活对象将进入老年代
虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应 该放在新生代,哪些对 象应该放在老年代。因此虚拟机给每个对象定义了一个对 象年龄的计数器,如果对象在 Eden 区出生, 并且能够被 Survivor 容纳,将被 移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区 中每「熬 过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升 到老年代。
虚拟机类加载机制
简述 java 类加载机制?
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验,解析和初 始化,最终形成可以被 虚拟机直接使用的 java 类型。
描述一下 JVM 加载 Class 文件的原理机制
Java 中的所有类,都需要由类加载器装载到 JVM 中才能运行。类加载器本身也 是一个类,而它的工作就 是把 class 文件从硬盘读取到内存中。在写程序的时 候,我们几乎不需要关心类的加载,因为这些都是隐 式装载的,除非我们有特殊 的用法,像是反射,就需要显式的加载所需要的类。
类装载方式,有两种 :
- 1.隐式装载, 程序在运行过程中当碰到通过 new 等方式生成对象时,隐式调用 类装载器加载对应的类到 jvm 中,
- 2.显式装载, 通过 class.forname() 等方法,显式加载需要的类
Java 类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证 程序运行的基础类(像是 基类)完全加载到 jvm 中,至于其他类,则在需要的时候 才加载。这当然就是为了节省内存开销。
什么是类加载器,类加载器有哪些?
实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。 主要有一下四种类加载器:
- 启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。
- 扩展类加载器(extensions class loader): 它用来加载 Java 的扩展库。 Java 虚拟机的实现会提供一 个扩展库目录。该类加载器在此目录里面查找 并加载 Java 类。
- 系统类加载器(system class loader ):它根据 Java 应用的类路径 (CLASSPATH )来加载 Java 类。一般来说,Java 应用的类都是由它来 完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取 它。
- 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实 现。
说一下类装载的执行过程?
类装载分为以下 5 个步骤:
- 加载:根据查找路径找到相应的 class 文件然后导入;
- 验证:检查加载的 class 文件的正确性;
- 准备:给类中的静态变量分配内存空间;
- 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为 一个标示,而在直接引 用直接指向内存中的地址;
- 初始化:对静态变量和静态代码块执行初始化工作
什么是双亲委派模型?
在介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的 类加载器和这个类本身 一同确立在 JVM 中的唯一性,每一个类加载器,都有一 个独立的类名称空间。类加载器就是根据指定全 限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。
类加载器分类:
- 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载 Java_HOME/lib/目 录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚 拟机识别的类库;
- 其他类加载器:
- 扩展类加载器(Extension ClassLoader):负责加载\lib\ext 目录或 Java. ext. dirs 系统变量指 定的路径中的所有类库;
- 应用程序类加载器(Application ClassLoader)。负责加载用户类路径 (classpath)上的指 定类库,我们可以直接使用这个类加载器。一般情况,如果我 们没有自定义类加载器默认就 是用这个加载器。
双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载 这个类,而是把这个请 求委派给父类加载器去完成,每一层的类加载器都是如 此,这样所有的加载请求都会被传送到顶层的启 动类加载器中,只有当父加载无 法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会 尝试去加 载类。
当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父 类,由父类去加载,如果 此时父类不能加载,反馈给子类,由子类去完成类的加 载。
JVM 调优
说一下 JVM 调优的工具?
JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视 图监控工具。
- jconsole:用于对 JVM 中的内存、线程和类等进行监控;
- jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序 死锁、监控内存的 变化、gc 变化等。
常用的 JVM 调优的参数都有哪些?
- -Xms2g:初始化推大小为 2g;
- -Xmx2g:堆最大内存为 2g;
- -XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
- -XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
- –XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
- -XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
- -XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组 合;
- -XX:+PrintGC:开启打印 gc 信息;
- -XX:+PrintGCDetails:打印 gc 详细信息。
JVM 内存模型,GC 机制和原理;
内存模型
- Jdk1.6 及之前:有永久代, 常量池在方法区
- Jdk1.7:有永久代,但已经逐步“去永久代”,常量池在堆
- Jdk1.8 及之后: 无永久代,常量池在元空间
GC 分哪两种,Minor GC 和 Full GC 有什么区别?什么时候会触发 Full GC?分别采用什么算法?
对象从新生代区域消失的过程,我们称之为 "minor GC"
对象从老年代区域消失的过程,我们称之为 "major GC"
Minor GC
清理整个 YouGen 的过程,eden 的清理,S0\S1 的清理都会由于 MinorGC Allocation Failure(YoungGen 区内存不足),而触发 minorGC
Major GC
OldGen 区内存不足,触发 Major GC
Full GC
Full GC 是清理整个堆空间—包括年轻代和永久代
Full GC 触发的场景
- 1)System.gc
- 2)promotion failed (年代晋升失败,比如 eden 区的存活对象晋升到 S 区放不下,又尝试直接晋 升到 Old 区又放不下,那么 Promotion Failed,会触发 FullGC)
- 3)CMS 的 Concurrent-Mode-Failure 由于 CMS 回收过程中主要分为四步:
- 1.CMS initial mark
- 2.CMS Concurrent mark
- 3.CMS remark
- 4.CMS Concurrent sweep。在 2 中 gc 线程与用户线程同时执行,那么用户线程依旧可 能同时产生垃圾, 如果这个垃圾较多无法放入预留的空间就会产生 CMS-Mode-Failure, 切换 为 SerialOld 单线程做 marksweep-compact。
- 4)新生代晋升的平均大小大于老年代的剩余空间 (为了避免新生代晋升到老年代失败) 当使用 G1,CMS 时,FullGC 发生的时候 是 Serial+SerialOld。 当使用 ParalOld 时,FullGC 发生的时候是 ParallNew +ParallOld.
JVM 里的有几种 classloader,为什么会有多种?
- 启动类加载器:负责加载 JRE 的核心类库,如 jre 目标下的 rt.jar,charsets.jar 等
- 扩展类加载器:负责加载 JRE 扩展目录 ext 中 JAR 类包
- 系统类加载器:负责加载 ClassPath 路径下的类包
- 用户自定义加载器:负责加载用户自定义路径下的类包
为什么会有多种:
- 1)分工,各自负责各自的区块
- 2)为了实现委托模型
什么是双亲委派机制?介绍一些运作过程,双亲委派模型的好处;
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的 加载器去 执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终 将到达顶层的启 动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载 器无法完成此加载任 务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都不 愿意干活,每次有活就丢给 父亲去干,直到父亲说这件事我也干不了时,儿子自己想办法去完 成,这不就是传说中的双亲委派模 式
好处
沙箱安全机制:自己写的 String.class 类不会被加载,这样便可以防止核心 API 库被随意篡改 避免类的重 复加载:当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次
常见的 JVM 调优方法有哪些?可以具体到调整哪个参数,调成什么值?
调优工具
console,jProfile,VisualVM
Dump 线程详细信息:查看线程内部运行情况
死锁检查
查看堆内类、对象信息查看:数量、类型等
线程监控
- 线程信息监控:系统线程数量。
- 线程状态监控:各个线程都处在什么样的状态下
热点分析
- CPU 热点:检查系统哪些方法占用的大量 CPU 时间
- 内存热点:检查哪些对象在系统中数量最大(一定时间内存活对象和销毁对象一起统计) 内存泄漏检查
JVM 虚拟机内存划分、类加载器、垃圾收集算法、垃圾收集器、class 文件结构是如何解析的;
JVM 虚拟机内存划分(重复)
类加载器(重复)
垃圾收集算法:标记-清除算法、复制算法、标记-整理算法、分代收集算法
垃圾收集器: Serial 收集器、ParNew 收集器、Parallel Scavenge 收集器、Serial Old 收集器、 Parallel Old 收集器、CMS 收集器、G1 收集器、Z 垃圾收集器
class 文件结构是如何解析的
类(class)文件结构
前面的内容我们了解到 jvm 的内存结构。所有 java 文件必须经过“编译”转成 class 文件之后才会被 jvm 所识 别和运用。那么我们开始了解一下类文件也就是 class 文件的结构。也就是我们写的 java 文件最终会被编 译成什么样?那种格式?
本文讲解内容借鉴了《Java 虚拟机规范(Java SE 7 版)》第四章。如果有兴趣可以自行阅读
1、类文件介绍
每一个 Class 文件都对应着唯一一个类或接口的定义信息,但是相对地,类或接口并不一定都得定义在 文件里(譬如类或接口也可以通过类加载器直接生成)。
本节中,我们只是通俗地将任意一个有效的类或接口所应当满足的格式称为“Class 文件格式”,即使它不 一定以磁盘文件的形式存在。
每个 Class 文件都是由 8 字节为单位的字节流组成,所有的 16 位、32 位和 64 位长度的数据将被构造 成 2 个、4 个和 8 个 8 字节单位来表示。多字节数据项总是按照 Big-Endian 的顺序进行存储。
注意:Big-Endian 顺序是指按高位字节在地址最低位,最低字节在地址最高位来存储数据,它是 SPARC、PowerPC 等处理器的默认多字节存储顺序,而 x86 等处理器则是使用了相反的 Little-Endian 顺 序来存储数据。为了保证 Class 文件在不同硬件上具备同样的含义,因此在 Java 虚拟机规范中是有必要 严格规定了数据存储顺序的。
在 Java SDK 中,访问这种格式的数据可以使用 java.io.DataInput、java.io.DataOutput 等接口和 java.io.DataInputStream 和 java.io.DataOutputStream 等类来实现。
本节内容,还定义了一组私有数据类型来表示 Class 文件的内容,它们包括 u1,u2 和 u4,分别代表了 1、2 和 4 个字节的无符号数。
在 Java SDK 中这些类型的数据可以通过实现接口 java.io.DataInput 中的 readUnsignedByte、 readUnsignedShort 和 readInt 方法进行读取。
本节将采用类似 C 语言结构体的伪结构来描述 Class 文件格式。为了避免与类的字段、类的实例等概念 产生混淆,在此把用于描述类结构格式的内容定义为项(Item)。
在 Class 文件中,各项按照严格顺序连续存放的,它们之间没有任何填充或对齐作为各项间的分隔符 号。
表(Table)是由任意数量的可变长度的项组成,用于表示 Class 文件内容的一系列复合结构。尽管我们 采用类似 C 语言的数组语法来表示表中的项,但是读者应当清楚意识到,表是由可变长数据组成的复合 结构(表中每项的长度不固定),因此无法直接将字节偏移量来作为索引对表进行访问。
而我们描述一个数据结构为数组(Array)时,就意味着它含有零至多个长度固定的项组成,这个时候则 可以采用数组索引的方式来访问它 。
注意:虽然原文中在此定义了“表”和“数组”的关系,但在后文中依然存在表和数组混用的情况。译文中作 了一些修正,把各个数据项结构不一致的数据集合用“表”那表示,譬如“constant_pool 表”、 “attributes 表”,而把数据项结构一致的数据集合用“数组”来表示,譬如“code[]数组“、“fields[]数组”。
2、ClassFile 结构
每一个 Class 文件对应于一个如下所示的 ClassFile 结构体。
ClassFile 结构体中,各项的含义描述如下: 1,无符号数,以 u1、u2、u4、u8 分别代表 1 个字节、2 个字节、4 个字节、8 个字节的无符号数 2,表是由多个无符号数或者其它表作为数据项构成的复合数据类型,所以表都以“_info”结尾,由多个无 符号数或其它表构成的复合数据类型
每个部分出现的次数和数量见下表(Class 文件格式):
magic
魔数,魔数的唯一作用是确定这个文件是否为一个能被虚拟机所接受的 Class 文件。魔数值固定为 0xCAFEBABE,不会改变。
minor_version、major_version
副版本号和主版本号,minor_version 和 major_version 的值分别表示 Class 文件的副、主版本。它 们共同构成了 Class 文件的格式版本号。譬如某个 Class 文件的主版本号为 M,副版本号为 m,那么这 个 Class 文件的格式版本号就确定为 M.m。Class 文件格式版本号大小的顺序为:1.5 < 2.0 < 2.1。 一个 Java 虚拟机实例只能支持特定范围内的主版本号(Mi 至 Mj)和 0 至特定范围内(0 至 m)的副版 本号。假设一个 Class 文件的格式版本号为 V,仅当 Mi.0 ≤ v ≤ Mj.m 成立时,这个 Class 文件才可以被此 Java 虚拟机支持。不同版本的 Java 虚拟机实现支持的版本号也不 同,高版本号的 Java 虚拟机实现可以支持低版本号的 Class 文件,反之则不成立 。
注意:Oracle 的 JDK 在 1.0.2 版本时,支持的 Class 格式版本号范围是 45.0 至 45.3;JDK 版本在 1.1.x 时,支持的 Class 格式版本号范围扩展至 45.0 至 45.65535;JDK 版本为 1. k 时(k ≥2)时,对应的 Class 文件格式版本号的范围是 45.0 至 44+k.0 下表列举了 Class 文件版本号
onstant_pool_count
常量池计数器,constant_pool_count 的值等于 constant_pool 表中的成员数加 1。constant_pool 表的 索引值只有在大于 0 且小于 constant_pool_count 时才会被 认为是有效的 ,对于 long 和 double 类型有例外情况,后续在讲解。 注意:虽然值为 0 的 constant_pool 索引是无效的,但其他用到常量池的数据结构可以使用索引 0 来表 示“不引用任何一个常量池项”的意思。
constant_pool[ ]
常量池,constant_pool 是一种表结构(这里需要列举一下表就会明白,这个在下面的例子中会有讲解 这个结构,返回来在读就会明白),它包含 Class 文件结构及其子结构中引用的所有字符串常量、类或 接口名、字段名和其它常量。常量池中的每一项都具备相同的格式特征——第一个字节作为类型标记用 于识别该项是哪种类型的常量,称为“tagbyte”。常量池的索引范围是 1 至 constant_pool_count−1。
1 常量池的项目类型
2 每一种类型的格式特征:这里用 CONSTANT_Class_info 举个例子:
常量池主要存放两大类常量:字面量(literal)和符号引用。字面量比较接近 java 语言层面的常量概念, 比如文本字符串、声明的 final 的常量值等。符号引用属于编译原理方面概念。包括下面三类常量:类和 接口的全局限定名。字段的名称和描述符。方法的名称和描述符。这三类稍后在详细讲解。
访问标志,access_flags 是一种掩码标志,用于表示某个类或者接口的访问权限及基础属性。 access_flags 的取值范围和相应含义见表(访问和修饰符标志)所示。
带有 ACC_SYNTHETIC 标志的类,意味着它是由编译器自己产生的而不是由程序员编写的源代码生成 的。
带有 ACC_ENUM 标志的类,意味着它或它的父类被声明为枚举类型。
带有 ACC_INTERFACE 标志的类,意味着它是接口而不是类,反之是类而不是接口。如果一个 Class 文 件被设置了 ACC_INTERFACE 标志,那么同时也得设置 ACC_ABSTRACT 标志(JLS §9.1.1.1)。同时它不 能再设置 ACC_FINAL、ACC_SUPER 和 ACC_ENUM 标志。
注解类型必定带有 ACC_ANNOTATION 标记,如果设置了 ANNOTATION 标记,ACC_INTERFACE 也必 须被同时设置。如果没有同时设置 ACC_INTERFACE 标记,那么这个 Class 文件可以具有表 4.1 中的除 ACC_ANNOTATION 外的所有其它标记。当然 ACC_FINAL 和 ACC_ABSTRACT 这类互斥的标记除外(JLS §8.1.1.2)。
ACC_SUPER 标志用于确定该 Class 文件里面的 invokespecial 指令使用的是哪一种执行语义。目前 Java 虚拟机的编译器都应当设置这个标志。ACC_SUPER 标记是为了向后兼容旧编译器编译的 Class 文件而存 在的,在 JDK1.0.2 版本以前的编译器产生的 Class 文件中,access_flag 里面没有 ACC_SUPER 标志。 同时,JDK1.0.2 前的 Java 虚拟机遇到 ACC_SUPER 标记会自动忽略它。
在表 4.1 中没有使用的 access_flags 标志位是为未来扩充而预留的,这些预留的标志为在编译器中会被 设置为 0, Java 虚拟机实现也会自动忽略它们。
this_class
类索引,this_class 的值必须是对 constant_pool 表中项目的一个有效索引值。constant_pool 表在这 个索引处的项必须为 CONSTANT_Class_info 类型常量,表示这个 Class 文件所定义的类或接口。
super_class
父类索引,对于类来说,super_class 的值必须为 0 或者是对 constant_pool 表中项目的一个有效索引 值。如果它的值不为 0,那 constant_pool 表在这个索引处的项 必须为 CONSTANT_Class_info 类型常量(§4.4.1),表示这个 Class 文件所定义的类的直接父类。当 前类的直接父类,以及它所有间接父类的 access_flag 中都不能带有 ACC_FINAL 标记。对于接口来说, 它的 Class 文件的 super_class 项的值必须是对 constant_pool 表中项目的一个有效索引值。 constant_pool 表在这个索引处的 项必须为代表 java.lang.Object 的 CONSTANT_Class_info 类型常量(§4.4.1)。如果 Class 文件的 super_class 的值为 0,那这个 Class 文件只可能是定义的是 java.lang.Object 类,只有它是唯一没有父类的类。
interfaces_count
接口计数器,interfaces_count 的值表示当前类或接口的直接父接口数量。
interfaces[]
接口表,interfaces[]数组中的每个成员的值必须是一个对 constant_pool 表中项目的一个有效索引值, 它的长度为 interfaces_count。每个成员 interfaces[i] 必须为 CONSTANT_Class_info 类型常量 (§4.4.1),其中 0 ≤ i <interfaces_count。在 interfaces[]数组中,成员所表示的接口顺序和对应的源 代码中给定的接口顺序(从左至右)一样,即 interfaces[0]对应的是源代码中最左边的接口。
- fields_count 字段计数器,fields_count 的值表示当前 Class 文件 fields[]数组的成员个数。fields[]数组中每一项都是 一个 field_info 结构(§4.5)的数据项,它用于表示该类或接口声明的类字段或者实例字段 。 注意::类字段即被声明为 static 的字段,也称为类变量或者类属性,同样,实例字段是指未被声明为 static 的字段。由于《Java 虚拟机规范》中,“Variable”和“Attribute”出现频率很高且在大多数场景中具 备其他含义,所以译文中统一把“Field”翻译为“字段”,即“类字段”、“实例字段”。
- fields[] 字段表,fields[]数组中的每个成员都必须是一个 fields_info 结构(§4.5)的数据项,用于表示当前类或 接口中某个字段的完整描述。fields[]数组描述当前类或接口 声明的所有字段,但不包括从父类或父接口继承的部分。
- methods_count 方法计数器,methods_count 的值表示当前 Class 文件 methods[]数组的成员个数。Methods[]数组中 每一项都是一个 method_info 结构(§4.5)的数据项。
- methods[] 方法表,methods[]数组中的每个成员都必须是一个 method_info 结构(§4.6)的数据项,用于表示当 前类或接口中某个方法的完整描述。如果某个 method_info 结构 的 access_flags 项既没有设置 ACC_NATIVE 标志也没有设置 ACC_ABSTRACT 标志,那么它所对应的方 法体就应当可以被 Java 虚拟机直接从当前类加载,而不需要引用其它类。method_info 结构可以表示类 和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法方法(§2.9)和类或接口初始化方 法方法(§2.9)。methods[]数组 只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。
- attributes_count 属性计数器,attributes_count 的值表示当前 Class 文件 attributes 表的成员个数。attributes 表中每 一项都是一个 attribute_info 结构(§4.7)的数据项。
- attributes[] 属性表,attributes 表的每个项的值必须是 attribute_info 结构(§4.7)。在本规范里,Class 文件结构 中的 attributes 表的项包括下列定义的属性: InnerClasses(§4.7.6)、EnclosingMethod(§4.7.7)、Synthetic(§4.7.8)、 Signature(§4.7.9)、SourceFile(§4.7.10),SourceDebugExtension(§4.7.11)、 Deprecated(§4.7.15)、RuntimeVisibleAnnotations(§4.7.16)、 RuntimeInvisibleAnnotations(§4.7.17)以及 BootstrapMethods(§4.7.21)属性。对于支持 Class 文件格式版本号为 49.0 或更高的 Java 虚拟机实现,必须正确识别并读取 attributes 表中的 Signature(§4.7.9)、RuntimeVisibleAnnotations(§4.7.16)和 RuntimeInvisibleAnnotations(§4.7.17)属性。对于支持 Class 文件格式版本号为 51.0 或更高的 Java 虚拟机实现,必须正确识别并读取 attributes 表中的 BootstrapMethods(§4.7.21)属性。本规范要求任一 Java 虚拟机实现可以自动忽略 Class 文件的 attributes 表中的若干(甚至全部)它不可识别的属性项。任何本规范未定义的属性不能影响 Class 文 件的语义,只能提供附加的描述信息(§4.7.1)。 这 16 个部分需要在实际应用中仔细研究一下。
列如下面代码:
经过编译之后 class 文件用 WinHex 编辑器打开你会发现
解读:
- 1>前 4 个字节 16 进制表示 0xCAFEBABE 固定不变的魔数。
- 2>看到第 5 字节和 6 字节表示副版本号 0x0000 和主版本号 0x0033 也就是十进制 51。查找 class 版本号可知 这个 class 文件可以被 JDK1.7.0 或者以上的虚拟机执行的 class 文件。
- 3>常量池计数器是从 1 开始计数,即上图中的偏移地址(0x00000008)即数字 16 那,换做十进制为 22,也就是常量池中有 21 个常量。索引值范围 1~21, 这里注意:将索引值设置为 0 时有特殊含义,不引用任何一个常量池项目的含义。Class 文件中只有常量池 的容量是从 1 计数开始。其它一般从 0 开始
- 4>常量池:常量池第一项,(偏移地址 0x0000000A)是 0x07,查看表 6-3 的标志发现它属于 CONSTANT_Class_info 类型。此类型的结构如上面的常量池的 6-4 图,其中 tag 是标志位已经说过了用于 区分常量类型,name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型的常量,这 里 name_index 的值(偏移地址 0x0000000B)为 0x0002 也指向了常量池的第二项。然后依次继续查 找。。。
分析了 TestClass.class 常量池中的两个,其余 19 个常量也可以继续计算出来。这里我们可以借助 jdk 的计 算器帮我们完成。在 JDK 的 bin 目录中有一个专门分析 class 文件的字节码工具:javap,使用命令 javap - verbose TestClass 参数输出文件字节码内容。如下:
F:\Java\jdk\jdk1.7.0_60\bin>javap -verbose TestClass.class
Classfile /F:/Java/jdk/jdk1.7.0_60/bin/TestClass.class
Last modified 2017-10-16; size 373 bytes
MD5 checksum 7d19b6fe8101f913758048f3529eaee4
Compiled from "TestClass.java"
public class com.clazz.TestClass
SourceFile: "TestClass.java"
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Class #2 // com/clazz/TestClass
#2 = Utf8 com/clazz/TestClass
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Methodref #3.#11 // java/lang/Object."<init>":()V
#11 = NameAndType #7:#8 // "<init>":()V
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lcom/clazz/TestClass;
#16 = Utf8 inc
#17 = Utf8 ()I
#18 = Fieldref #1.#19 // com/clazz/TestClass.m:I
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 SourceFile
#21 = Utf8 TestClass.java
{
public com.clazz.TestClass();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>
":()V
4: return
- 5>常量池结束后,紧接着的两个字节代表访问标识(access_flags)偏移量(0x000000DC)为 0x0021=0x0001
User user = new User() 做了什么操作,申请了哪些内存?
- new User(); 创建一个 User 对象,内存分配在堆上
- User user; 创建一个引用,内存分配在栈上
- = 将 User 对象地址赋值给引用
Java 的内存模型以及 GC 算法
JVM 内存模型与 GC 算法
1.JVM 内存模型
执行引擎,运行时数据区(方法区,堆,虚拟机栈,本地方法栈,程序计数器),本地库接口,本地方法库
程序计数器 程序计数器是众多编程语言都共有的一部分,作用是标示下一条需要执行的指令的位置,分支、循环、 跳转、异常处理、线程恢复等基础功能都是依赖程序计数器完成的。 对于 Java 的多线程程序而言,不同的线程都是通过轮流获得 cpu 的时间片运行的,这符合计算机组成原 理的基本概念,因此不同的线程之间需要不停的获得运行,挂起等待运行,所以各线程之间的计数器互 不影响,独立存储。这些数据区属于线程私有的内存。
Java 虚拟机栈
VM 虚拟机栈也是线程私有的,生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每 个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法 出口等信息。每一个方法调用直至执行完的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。 有人将 java 内存区域划分为栈与堆两部分,在这种粗略的划分下,栈标示的就是当前讲的虚拟机栈,或 者是虚拟机栈对应的局部变量表。之所以说这种划分比较粗略是角度不同,这种划分方法关心的是新申 请内存的存在空间,而我们目前谈论的是 JVM 整体的内存划分,由于角度不同,所以划分的方法不同, 没有对与错。
局部变量表存放了编译期可知的各种基本类型,对象引用,和 returnAddress。其中 64 位长的 long 和 double 占用了 2 个局部变量空间(slot),其他类型都占用 1 个。这也从存储的角度上说明了 long 与 double 本质上的非原子性。局部变量表所需的内存在编译期间完成分配,当进入一个方法时,这个方法在栈帧 中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。
由于栈帧的进出栈,显而易见的带来了空间分配上的问题。如果线程请求的栈深度大于虚拟机所允许的 深度,将抛出 StackOverFlowError 异常;如果虚拟机栈可以扩展,扩展时无法申请到足够的内存,将会 抛出 OutOfMemoryError。显然,这种情况大多数是由于循环调用与递归带来的。
本地方法栈 本地方法栈与虚拟机栈的作用十分类似,不过本地方法是为 native 方法服务的。部分虚拟机(比如 Sun HotSpot 虚拟机)直接将本地方法栈与虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出 StactOverFlowError 与 OutOfMemoryError 异常。 至此,线程私有数据区域结束,下面开始线程共享数据区。
Java 堆 Java 堆是虚拟机所管理的内存中最大的一块,在虚拟机启动时创建,此块内存的唯一目的就是存放对象 实例,几乎所有的对象实例都在对上分配内存。JVM 规范中的描述是:所有的对象实例以及数据都要在 堆上分配。但是随着 JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配(对象只存在于某方法中,不 会逃逸出去,因此方法出栈后就会销毁,此时对象可以在栈上分配,方便销毁),标量替换(新对象拥有的 属性可以由现有对象替换拼凑而成,就没必要真正生成这个对象)等优化技术带来了一些变化,目前并非 所有的对象都在堆上分配了。
当 java 堆上没有内存完成实例分配,并且堆大小也无法扩展是,将会抛出 OutOfMemoryError 异常。 Java 堆是垃圾收集器管理的主要区域。
方法区 方法区与 java 堆一样,是线程共享的数据区,用于存储被虚拟机加载的类信息、常量、静态变量、即时 编译的代码。JVM 规范将方法与堆区分开,但是 HotSpot 将方法区作为永久代(Permanent Generation) 实现。这样方便将 GC 分代手机方法扩展至方法区,HotSpot 的垃圾收集器可以像管理 Java 堆一样管理方 法区。但是这种方向已经逐步在被 HotSpot 替换中,在 JDK1.7 的版本中,已经把原本存放在方法区的字 符串常量区移出。
至此,JVM 规范所声明的内存模型已经分析完毕,下面将分析一些经常提到的与内存相关的区域。
- 运行时常量池 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等信息外,还有一 项信息是常量池(Constant Poll Table)用于存放编译期生成的各种字面量和符号引用,这部分内容将在类 加载后进入方法区的运行时常量池存放。 其中字符串常量池属于运行时常量池的一部分,不过在 HotSpot 虚拟机中,JDK1.7 将字符串常量池移到 了 java 堆中,通过下面的实验可以很容易看到。
在 jdk1.6 中,字符串常量区是在 Perm Space 中的,所以可以将 Perm Spacce 设置的小一些, XX:MaxPermSize=10M 可以很快抛出异常:java.lang.OutOfMemoryError:Perm Space。 在 jdk1.7 以上,字符串常量区已经移到了 Java 堆中,设置-Xms:64m -Xmx:64m,很快就可以抛出异常 java.lang.OutOfMemoryError:java.heap.space。
- 直接内存 直接内存不是 JVM 运行时的数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。在 JDK1.4 中引 入了 NIO(New Input/Output)类,引入了一种基于通道(Chanel)与缓冲区(Buffer)的 I/O 方式,他可以使 用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 中的 DirectByteBuffer 对象作为对这块内存 的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 对和 Native 对中来回复制数 据
GC 算法
标记-清除算法 最基础的垃圾收集算法是“标记-清除”(Mark Sweep)算法,正如名字一样,算法分为 2 个阶段:1.标记处 需要回收的对象,2.回收被标记的对象。标记算法分为两种:1.引用计数算法(Reference Counting) 2.可 达性分析算法(Reachability Analysis)。由于引用技术算法无法解决循环引用的问题,所以这里使用的标 记算法均为可达性分析算法。
如图所示,当进行过标记清除算法之后,出现了大量的非连续内存。当 java 堆需要分配一段连续的内存 给一个新对象时,发现虽然内存清理出了很多的空闲,但是仍然需要继续清理以满足“连续空间”的要 求。所以说,这种方法比较基础,效率也比较低下。
复制算法 为了解决效率与内存碎片问题,复制(Copying)算法出现了,它将内存划分为两块相等的大小,每次使用 一块,当这一块用完了,就讲还存活的对象复制到另外一块内存区域中,然后将当前内存空间一次性清 理掉。这样的对整个半区进行回收,分配时按照顺序从内存顶端依次分配,这种实现简单,运行高效。 不过这种算法将原有的内存空间减少为实际的一半,代价比较高。
从图中可以看出,整理后的内存十分规整,但是白白浪费一般的内存成本太高。然而这其实是很重要的 一个收集算法,因为现在的商业虚拟机都采用这种算法来回收新生代。IBM 公司的专门研究表明,新生 代中的对象 98%都是“朝生夕死”的,所以不需要按照 1:1 的比例来划分内存。HotSpot 虚拟机将 Java 堆划 分为年轻代(Young Generation)、老年代(Tenured Generation),其中年轻代又分为一块 Eden 和两块 Survivor。
所有的新建对象都放在年轻代中,年轻代使用的 GC 算法就是复制算法。其中 Eden 与 Survivor 的内存大 小比例为 8:2,其中 Eden 由 1 大块组成,Survivor 由 2 小块组成。每次使用内存为 1Eden+1Survivor,即 90%的内存。由于年轻代中的对象生命周期往往很短,所以当需要进行 GC 的时候就将当前 90%中存活的 对象复制到另外一块 Survivor 中,原来的 Eden 与 Survivor 将被清空。但是这就有一个问题,我们无法保 证每次年轻代 GC 后存活的对象都不高于 10%。所以在当活下来的对象高于 10%的时候,这部分对象将由 Tenured 进行担保,即无法复制到 Survivor 中的对象将移动到老年代。
- 标记-整理算法 复制算法在极端情况下(存活对象较多)效率变得很低,并且需要有额外的空间进行分配担保。所以在老 年代中这种情况一般是不适合的
所以就出现了标记-整理(Mark-Compact)算法。与标记清除算法一样,首先是标记对象,然而第二步是 将存货的对象向内存一段移动,整理出一块较大的连续内存空间。
3. 总结
- Java 虚拟机规范中规定了对内存的分配,其中程序计数器、本地方法栈、虚拟机栈属于线程私有数 据区,Java 堆与方法区属于线程共享数据。
- Jdk 从 1.7 开始将字符串常量区由方法区(永久代)移动到了 Java 堆中。
- Java 从 NIO 开始允许直接操纵系统的直接内存,在部分场景中效率很高,因为避免了在 Java 堆与 Native 堆中来回复制数据。
- Java 堆分为年轻代有年老代,其中年轻代分为 1 个 Eden 与 2 个 Survior,同时只有 1 个 Eden 与 1 个 Survior 处于使用中状态,又有年轻代的对象生存时间为往往很短,因此使用复制算法进行垃圾回 收。
- 年老代由于对象存活期比较长,并且没有可担保的数据区,所以往往使用标记-清除与标记-整理算 法进行垃圾回收。
jvm 性能调优都做了什么
JVM 性能调优
JVM 内存模型及垃圾收集算法
1.根据 Java 虚拟机规范,JVM 将内存划分为:
- New(年轻代)
- Tenured(年老代)
- 永久代(Perm)
其中 New 和 Tenured 属于堆内存,堆内存会从 JVM 启动参数(-Xmx:3G)指定的内存中分配,Perm 不属 于堆内存,有虚拟机直接分配,但可以通过-XX:PermSize -XX:MaxPermSize 等参数调整其大小。
- 年轻代(New):年轻代用来存放 JVM 刚分配的 Java 对象
- 年老代(Tenured):年轻代中经过垃圾回收没有回收掉的对象将被 Copy 到年老代
- 永久代(Perm):永久代存放 Class、Method 元信息,其大小跟项目的规模、类、方法的量有 关,一般设置为 128M 就足够,设置原则是预留 30%的空间。
New 又分为几个部分:
- Eden:Eden 用来存放 JVM 刚分配的对象
- Survivor1
- Survivro2:两个 Survivor 空间一样大,当 Eden 中的对象经过垃圾回收没有被回收掉时,会在两个 Survivor 之间来回 Copy,当满足某个条件,比如 Copy 次数,就会被 Copy 到 Tenured。显然, Survivor 只是增加了对象在年轻代中的逗留时间,增加了被垃圾回收的可能性。
2.垃圾回收算法 垃圾回收算法可以分为三类,都基于标记-清除(复制)算法:
- Serial 算法(单线程)
- 并行算法
- 并发算法
JVM 会根据机器的硬件配置对每个内存代选择适合的回收算法,比如,如果机器多于 1 个核,会对年轻代 选择并行算法,关于选择细节请参考 JVM 调优文档。
稍微解释下的是,并行算法是用多线程进行垃圾回收,回收期间会暂停程序的执行,而并发算法,也是 多线程回收,但期间不停止应用执行。所以,并发算法适用于交互性高的一些程序。经过观察,并发算 法会减少年轻代的大小,其实就是使用了一个大的年老代,这反过来跟并行算法相比吞吐量相对较低。
还有一个问题是,垃圾回收动作何时执行?
当年轻代内存满时,会引发一次普通 GC,该 GC 仅回收年轻代。需要强调的时,年轻代满是指 Eden 代满,Survivor 满不会引发 GC
当年老代满时会引发 Full GC,Full GC 将会同时回收年轻代、年老代
当永久代满时也会引发 Full GC,会导致 Class、Method 元信息的卸载
另一个问题是,何时会抛出 OutOfMemoryException,
并不是内存被耗空的时候才抛出 JVM98%的时间都花费在内存回收每次回收的内存小于 2% 满足这两个条件将触发 OutOfMemoryException,这将会留给系统一个微小的间隙以做一些 Down 之前 的操作,比如手动打印 Heap Dump。
内存泄漏及解决方法
1.系统崩溃前的一些现象:
每次垃圾回收的时间越来越长,由之前的 10ms 延长到 50ms 左右,FullGC 的时间也有之前的 0.5s 延 长到 4、5s
FullGC 的次数越来越多,最频繁时隔不到 1 分钟就进行一次 FullGC
年老代的内存越来越大并且每次 FullGC 后年老代没有内存被释放
之后系统会无法响应新的请求,逐渐到达 OutOfMemoryError 的临界值。
2.生成堆的 dump 文件
通过 JMX 的 MBean 生成当前的 Heap 信息,大小为一个 3G(整个堆的大小)的 hprof 文件,如果没有启 动 JMX 可以通过 Java 的 jmap 命令来生成该文件。
3.分析 dump 文件
下面要考虑的是如何打开这个 3G 的堆信息文件,显然一般的 Window 系统没有这么大的内存,必须借助 高配置的 Linux。当然我们可以借助 X-Window 把 Linux 上的图形导入到 Window。我们考虑用下面几种工 具打开该文件:
- Visual VM
- IBM HeapAnalyzer
- JDK 自带的 Hprof 工具
使用这些工具时为了确保加载速度,建议设置最大内存为 6G。使用后发现,这些工具都无法直观地观察 到内存泄漏,Visual VM 虽能观察到对象大小,但看不到调用堆栈;HeapAnalyzer 虽然能看到调用堆 栈,却无法正确打开一个 3G 的文件。因此,我们又选用了 Eclipse 专门的静态内存分析工具:Mat。
4.分析内存泄漏
通过 Mat 我们能清楚地看到,哪些对象被怀疑为内存泄漏,哪些对象占的空间最大及对象的调用关系。 针对本案,在 ThreadLocal 中有很多的 JbpmContext 实例,经过调查是 JBPM 的 Context 没有关闭所致。 另,通过 Mat 或 JMX 我们还可以分析线程状态,可以观察到线程被阻塞在哪个对象上,从而判断系统的 瓶颈。
5.回归问题
Q:为什么崩溃前垃圾回收的时间越来越长?
A:根据内存模型和垃圾回收算法,垃圾回收分两部分:内存标记、清除(复制),标记部分只要内存大 小固定时间是不变的,变的是复制部分,因为每次垃圾回收都有一些回收不掉的内存,所以增加了复制 量,导致时间延长。所以,垃圾回收的时间也可以作为判断内存泄漏的依据
Q:为什么 Full GC 的次数越来越多?
A:因此内存的积累,逐渐耗尽了年老代的内存,导致新对象分配没有更多的空间,从而导致频繁的垃 圾回收
Q:为什么年老代占用的内存越来越大?
A:因为年轻代的内存无法被回收,越来越多地被 Copy 到年老代
性能调优
除了上述内存泄漏外,我们还发现 CPU 长期不足 3%,系统吞吐量不够,针对 8core×16G、64bit 的 Linux 服务器来说,是严重的资源浪费。
在 CPU 负载不足的同时,偶尔会有用户反映请求的时间过长,我们意识到必须对程序及 JVM 进行调优。
从以下几个方面进行:
- 线程池:解决用户响应时间长的问题
- 连接池
- JVM 启动参数:调整各代的内存比例和垃圾回收算法,提高吞吐量
- 程序算法:改进程序逻辑算法提高性能
Java 线程池(java.util.concurrent.ThreadPoolExecutor)
大多数 JVM6 上的应用采用的线程池都是 JDK 自带的线程池,之所以把成熟的 Java 线程池进行罗嗦说明, 是因为该线程池的行为与我们想象的有点出入。Java 线程池有几个重要的配置参数:
corePoolSize:核心线程数(最新线程数)
maximumPoolSize:最大线程数,超过这个数量的任务会被拒绝,用户可以通过 RejectedExecutionHandler 接口自定义处理方式
keepAliveTime:线程保持活动的时间
workQueue:工作队列,存放执行的任务 Java 线程池需要传入一个 Queue 参数(workQueue)用来存放执行的任务,而对 Queue 的不同选 择,线程池有完全不同的行为:
SynchronousQueue: ``一个无容量的等待队列,一个线程的 insert 操作必须等待另一线程的 remove 操 作,采用这个 Queue 线程池将会为每个任务分配一个新线程
LinkedBlockingQueue : 无界队列,采用该 Queue,线程池将忽略 maximumPoolSize 参数,仅用 corePoolSize 的线程处理所有的任务,未处理的任务便在 LinkedBlockingQueue 中排队
ArrayBlockingQueue: 有界队列,在有界队列和 maximumPoolSize 的作用下,程序将很难被调 优:更大的 Queue 和小的 maximumPoolSize 将导致 CPU 的低负载;小的 Queue 和大的池,Queue 就没起动应有的作用。
其实我们的要求很简单,希望线程池能跟连接池一样,能设置最小线程数、最大线程数,当最小数 <任务<最大数时,应该分配新的线程处理;当任务>最大数时,应该等待有空闲线程再处理该任 务。
但线程池的设计思路是,任务应该放到 Queue 中,当 Queue 放不下时再考虑用新线程处理,如果 Queue 满且无法派生新线程,就拒绝该任务。设计导致“先放等执行”、“放不下再执行”、“拒绝不等 待”。所以,根据不同的 Queue 参数,要提高吞吐量不能一味地增大 maximumPoolSize。
当然,要达到我们的目标,必须对线程池进行一定的封装,幸运的是 ThreadPoolExecutor 中留了 足够的自定义接口以帮助我们达到目标。我们封装的方式是:
以 SynchronousQueue 作为参数,使 maximumPoolSize 发挥作用,以防止线程被无限制的分配, 同时可以通过提高 maximumPoolSize 来提高系统吞吐量
自定义一个 RejectedExecutionHandler,当线程数超过 maximumPoolSize 时进行处理,处理方式 为隔一段时间检查线程池是否可以执行新 Task,如果可以把拒绝的 Task 重新放入到线程池,检查的 时间依赖 keepAliveTime 的大小。
连接池(org.apache.commons.dbcp.BasicDataSource)
在使用 org.apache.commons.dbcp.BasicDataSource 的时候,因为之前采用了默认配置,所以当访问 量大时,通过 JMX 观察到很多 Tomcat 线程都阻塞在 BasicDataSource 使用的 Apache ObjectPool 的锁 上,直接原因当时是因为 BasicDataSource 连接池的最大连接数设置的太小,默认的 BasicDataSource 配置,仅使用 8 个最大连接。
我还观察到一个问题,当较长的时间不访问系统,比如 2 天,DB 上的 Mysql 会断掉所以的连接,导致连 接池中缓存的连接不能用。为了解决这些问题,我们充分研究了 BasicDataSource,发现了一些优化的 点:
- Mysql 默认支持 100 个链接,所以每个连接池的配置要根据集群中的机器数进行,如有 2 台服务器, 可每个设置为 60
- initialSize:参数是一直打开的连接数
- minEvictableIdleTimeMillis:该参数设置每个连接的空闲时间,超过这个时间连接将被关闭
- timeBetweenEvictionRunsMillis:后台线程的运行周期,用来检测过期连接
- maxActive:最大能分配的连接数
- maxIdle:最大空闲数,当连接使用完毕后发现连接数大于 maxIdle,连接将被直接关闭。只有 initialSize < x < maxIdle 的连接将被定期检测是否超期。这个参数主要用来在峰值访问时提高吞吐 量。
- initialSize 是如何保持的?经过研究代码发现,BasicDataSource 会关闭所有超期的连接,然后再打 开 initialSize 数量的连接,这个特性与 minEvictableIdleTimeMillis、 timeBetweenEvictionRunsMillis 一起保证了所有超期的 initialSize 连接都会被重新连接,从而避免 了 Mysql 长时间无动作会断掉连接的问题。
JVM 参数
在 JVM 启动参数中,可以设置跟内存、垃圾回收相关的一些参数设置,默认情况不做任何设置 JVM 会工 作的很好,但对一些配置很好的 Server 和具体的应用必须仔细调优才能获得最佳性能。通过设置我们希 望达到一些目标:
- GC 的时间足够的小
- GC 的次数足够的少
- 发生 Full GC 的周期足够的长
前两个目前是相悖的,要想 GC 时间小必须要一个更小的堆,要保证 GC 次数足够少,必须保证一个更大 的堆,我们只能取其平衡。
(1)针对 JVM 堆的设置,一般可以通过-Xms -Xmx 限定其最小、最大值,为了防止垃圾收集器在最 小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值
(2)年轻代和年老代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率 NewRadio 来调整二者之间的大小,也可以针对回收代,比如年轻代,通过 -XX:newSize - XX:MaxNewSize 来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize - XX:MaxNewSize 设置为同样大小
(3)年轻代和年老代设置多大才算合理?这个我问题毫无疑问是没有答案的,否则也就不会有调优。
我们观察一下二者大小变化有哪些影响
更大的年轻代必然导致更小的年老代,大的年轻代会延长普通 GC 的周期,但会增加每次 GC 的时 间;小的年老代会导致更频繁的 Full GC
更小的年轻代必然导致更大年老代,小的年轻代会导致普通 GC 很频繁,但每次的 GC 时间会更短; 大的年老代会减少 Full GC 的频率
如何选择应该依赖应用程序对象生命周期的分布情况:如果应用存在大量的临时对象,应该选择更 大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。但很多应用都没有这样明显的 特性,在抉择时应该根据以下两点:(A)本着 Full GC 尽量少的原则,让年老代尽量缓存常用对 象,JVM 的默认比例 1:2 也是这个道理 (B)通过观察应用一段时间,看其他在峰值时年老代会占 多少内存,在不影响 Full GC 的前提下,根据实际情况加大年轻代,比如可以把比例控制在 1:1。 但应该给年老代至少预留 1/3 的增长空间
4)在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: - XX:+UseParallelOldGC ,默认为 Serial 收集
(5)线程堆栈的设置:每个线程默认会开启 1M 的堆栈,用于存放栈帧、调用参数、局部变量等,对大 多数应用而言这个默认值太了,一般 256K 就足用。理论上,在内存不变的情况下,减少每个线程的堆 栈,可以产生更多的线程,但这实际上还受限于操作系统。
- (4)可以通过下面的参数打 Heap Dump 信息
- -XX:HeapDumpPath
- -XX:+PrintGCDetails
- -XX:+PrintGCTimeStamps
- -Xloggc:/usr/aaa/dump/heap_trace.txt 通过下面参数可以控制 OutOfMemoryError 时打印堆的信息
- -XX:+HeapDumpOnOutOfMemoryError
请看一下一个时间的 Java 参数配置:(服务器:Linux 64Bit,8Core×16G)
JAVA_OPTS="$JAVA_OPTS -server -Xms3G -Xmx3G -Xss256k -XX:PermSize=128m - XX:MaxPermSize=128m -XX:+UseParallelOldGC -XX:+HeapDumpOnOutOfMemoryError - XX:HeapDumpPath=/usr/aaa/dump -XX:+PrintGCDetails -XX:+PrintGCTimeStamps - Xloggc:/usr/aaa/dump/heap_trace.txt -XX:NewSize=1G -XX:MaxNewSize=1G" 经过观察该配置非常稳定,每次普通 GC 的时间在 10ms 左右,Full GC 基本不发生,或隔很长很长的时间 才发生一次
通过分析 dump 文件可以发现,每个 1 小时都会发生一次 Full GC,经过多方求证,只要在 JVM 中开启了 JMX 服务,JMX 将会 1 小时执行一次 Full GC 以清除引用,关于这点请参考附件文档。
4.程序算法调优
调优方法 一切都是为了这一步,调优,在调优之前,我们需要记住下面的原则:
- 1、多数的 Java 应用不需要在服务器上进行 GC 优化;
- 2、多数导致 GC 问题的 Java 应用,都不是因为我们参数设置错误,而是代码问题;
- 3、在应用上线之前,先考虑将机器的 JVM 参数设置到最优(最适合);
- 4、减少创建对象的数量;
- 5、减少使用全局变量和大对象;
- 6、GC 优化是到最后不得已才采用的手段;
- 7、在实际使用中,分析 GC 情况优化代码比优化 GC 参数要多得多;
GC 优化的目的有两个:
- 1、将转移到老年代的对象数量降低到最小;
- 2、减少 full GC 的执行时间;
为了达到上面的目的,一般地,你需要做的事情有:
- 1、减少使用全局变量和大对象;
- 2、调整新生代的大小到最合适;
- 3、设置老年代的大小为最合适;
- 4、选择合适的 GC 收集器;
在上面的 4 条方法中,用了几个“合适”,那究竟什么才算合适,一般的,请参考上面“收集器搭配”和“启动 内存分配”两节中的建议。但这些建议不是万能的,需要根据您的机器和应用情况进行发展和变化,实际 操作中,可以将两台机器分别设置成不同的 GC 参数,并且进行对比,选用那些确实提高了性能或减少了 GC 时间的参数。
真正熟练的使用 GC 调优,是建立在多次进行 GC 监控和调优的实战经验上的,进行监控和调优的一般步 骤为:
- 1,监控 GC 的状态 使用各种 JVM 工具,查看当前日志,分析当前 JVM 参数设置,并且分析当前堆内存快照和 gc 日志,根据实 际的各区域内存划分和 GC 执行时间,觉得是否进行优化;
- 2,分析结果,判断是否需要优化 如果各项参数设置合理,系统没有超时日志出现,GC 频率不高,GC 耗时不高,那么没有必要进行 GC 优 化;如果 GC 时间超过 1-3 秒,或者频繁 GC,则必须优化; 注:如果满足下面的指标,则一般不需要进行 GC:
- Minor GC 执行时间不到 50ms;
- Minor GC 执行不频繁,约 10 秒一次;
- Full GC 执行时间不到 1s;
- Full GC 执行频率不算频繁,不低于 10 分钟 1 次;
- 3,调整 GC 类型和内存分配 如果内存分配过大或过小,或者采用的 GC 收集器比较慢,则应该优先调整这些参数,并且先找 1 台或几 台机器进行 beta,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择;
- 4,不断的分析和调整 通过不断的试验和试错,分析并找到最合适的参数
- 5,全面应用参数 如果找到了最合适的参数,则将这些参数应用到所有服务器,并进行后续跟踪。
调优实例
上面的内容都是纸上谈兵,下面我们以一些真实例子来进行说明:
实例 1: 笔者昨日发现部分开发测试机器出现异常:java.lang.OutOfMemoryError: GC overhead limit exceeded,这个异常代表:
GC 为了释放很小的空间却耗费了太多的时间,其原因一般有两个:1,堆太小,2,有死循环或大对象;
笔者首先排除了第 2 个原因,因为这个应用同时是在线上运行的,如果有问题,早就挂了。所以怀疑是这 台机器中堆设置太小;
使用 ps -ef |grep "java"查看,发现:
该应用的堆区设置只有 768m,而机器内存有 2g,机器上只跑这一个 java 应用,没有其他需要占用内存 的地方。另外,这个应用比较大,需要占用的内存也比较多;
笔者通过上面的情况判断,只需要改变堆中各区域的大小设置即可,于是改成下面的情况:
实例 2:
一个服务系统,经常出现卡顿,分析原因,发现 Full GC 时间太长:
jstat -gcutil:
S0 S1 E O P YGC YGCT FGC FGCT GCT 12.16 0.00 5.18 63.78 20.32 54 2.047 5 6.946 8.993
分析上面的数据,发现 Young GC 执行了 54 次,耗时 2.047 秒,每次 Young GC 耗时 37ms,在正常范围, 而 Full GC 执行了 5 次,耗时 6.946 秒,每次平均 1.389s,数据显示出来的问题是:Full GC 耗时较长,分 析该系统的是指发现,NewRatio=9,也就是说,新生代和老生代大小之比为 1:9,这就是问题的原因:
- 1,新生代太小,导致对象提前进入老年代,触发老年代发生 Full GC;
- 2,老年代较大,进行 Full GC 时耗时较大;
优化的方法是调整 NewRatio 的值,调整到 4,发现 Full GC 没有再发生,只有 Young GC 在执行。这就是 把对象控制在新生代就清理掉,没有进入老年代(这种做法对一些应用是很有用的,但并不是对所有应 用都要这么做)
实例 3:
一应用在性能测试过程中,发现内存占用率很高,Full GC 频繁,使用 sudo -u admin -H jmap - dump:format=b,file=文件名.hprof pid 来 dump 内存,生成 dump 文件,并使用 Eclipse 下的 mat 差距进 行分析,发现:
从图中可以看出,这个线程存在问题,队列 LinkedBlockingQueue 所引用的大量对象并未释放,导致整 个线程占用内存高达 378m,此时通知开发人员进行代码优化,将相关对象释放掉即可。
java classload 机制 详解
类加载器是 Java 语言的一个创新,也是 Java 语言流行的重要原因之一。它使得 Java 类可以被动态加载 到 Java 虚拟机中并执行。类加载器从 JDK 1.0 就出现了,最初是为了满足 Java Applet 的需要而开发出 来的。Java Applet 需要从远程下载 Java 类文件到浏览器中并执行。现在类加载器在 Web 容器和 OSGi 中得到了广泛的使用。一般来说,Java 应用的开发人员不需要直接同类加载器进行交互。Java 虚拟机默 认的行为就已经足够满足大多数情况的需求了。不过如果遇到了需要与类加载器进行交互的情况,而对 类加载器的机制又不是很了解的话,就很容易花大量的时间去调试 ClassNotFoundException 和 NoClassDefFoundError 等异常。本文将详细介绍 Java 的类加载器,帮助 读者深刻理解 Java 语言中的这个重要概念。下面首先介绍一些相关的基本概念。
类加载器基本概念
类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方 式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文 件)。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例。每个这样的实例用 来表示一个 Java 类。通过此实例的 newInstance()方法就可以创建出该类的一个对象。实际的情况可能 更加复杂,比如 Java 字节代码可能是通过工具动态生成的,也可能是通过网络下载的。 基本上所有的 类加载器都是 java.lang.ClassLoader 类的一个实例。下面详细介绍这个 Java 类。
java.lang.ClassLoader 类介绍
java.lang.ClassLoader 类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节代 码,然后从这些字节代码中定义出一个 ava 类,即 java.lang.Class 类的一个实例。除此之外, ClassLoader 还负责加载 Java 应用所需的资源,如图像文件和配置文件等。不过本文只讨论其加载类的功能。为了完成加载类的这个 职责,ClassLoader 提供了一系列的方法,比较重要的方法如 表 1 所示。关于这些方法的细节会在下面 进行介绍。
类加载器的树状组织结构
Java 中的类加载器大致可以分成两类,一类是系统提供的,另外一类则是由 Java 应用开发人员编写 的。系统提供的类加载器主要有下面三个:
引导类加载器(bootstrap class loader):它用来加载 Java 的核心库,是用原生代码来实现 的,并不继承自 java.lang.ClassLoader。
扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提 供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
系统类加载器(system class loader/App classloader):它根据 Java 应用的类路径 (CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader() 来获取它。
除了系统提供的类加载器以外,开发人员可以通过继承 java.lang.ClassLoader 类的方式实现自己 的类加载器,以满足一些特殊的需求。
除了引导类加载器之外,所有的类加载器都有一个父类加载器。通过 表 1 中给出的 getParent()方法可以得到。对于系统提供的类加载器来说,system classloader 的父类加载器是 extensions cloassloader,而 extensions cloassloader 的父类加载器是 bootstrap classloader;对于开发人员 编写的类加载器来说,其父类加载器是加载此类加载器 Java 类的类加载器。因为类加载器 Java 类 如同其它的 Java 类一样,也是要由类加载器来加载的。一般来说,开发人员编写的类加载器的父 类加载器是 system classloader。类加载器通过这种方式组织起来,形成树状结构。树的根节点就 是 bootstrap classloader。图 1 中给出了一个典型的类加载器树状组织结构示意图,其中的箭头指 向的是父类加载器。
public class ClassLoadDemo {
public static void main(String[] args) {
ClassLoader demo = ClassLoadDemo.class.getClassLoader();
while (demo != null) {
System.out.println(demo.getClass().getName());
demo = demo.getParent();
}
System.out.println(demo);
}
}
第一个输出的是 ClassLoaderTree 类的类加载器,即系统类加载器。它是 sun.misc.Launcher$AppClassLoader 类的实例;第二个输出的是扩展类加载器,是 sun.misc.Launcher$ExtClassLoader 类的实例。需要注意的是这里并没有输出引导类加载器,这是 由于有些 JDK 的实现对于父类加载器是引导类加载器的情况, getParent() 方法返回 null 。
类加载器的代理模式
在了解了类加载器的树状组织结构之后,下面介绍类加载器的代理模式。 类加载器在尝试自己去查找某个类的字节代码并定义它时,会先代理给其父类加载器,由父类加载器 先去尝试加载这个类,依次类推。在介绍代理模式背后的动机之前,首先需要说明一下 Java 虚拟机是如 何判定两个 Java 类是相同的。Java *虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器 是否一样。只有两者都相同的情况,才认为两个类是相同的。即便是同样的字节代码,被不同的类加载 器加载之后所得到的类,也是不同的。*比如一个 Java 类 com.tao.test. ClassTest,编译之后生成了 字节代码文件 ClassTest.class 。两个不同的类加载器 ClassLoaderA 和 ClassLoaderB 分别读取了 这个 ClassTest.class 文件,并定义出两个 java.lang.Class 类的实例来表示这个类。这两个实例是 不相同的。对于 Java 虚拟机来说,它们是不同的类。试图对这两个类的对象进行相互赋值,会抛出运行 时异常 ClassCastException 。下面通过上面一讲的示例来具体比较
所以,可以很清楚的看到,加载一个类的过程,它是层层的像父类委托,然后在层层的向下加载。用这 样的一种委托机制,到底有什么好处呢?为什么要这样做呢? 原因如下:
1、节约系统资源。只要,这个类已经被加载过了,就不会在次加载。
2、保证 Java 核心库的类型安全。所有 Java 应用都至少需要引用 java.lang.Object 类,也就是说在 运行的时候, java.lang.Object 这个类需要被加载到 Java 虚拟机中。如果这个加载过程由 Java 应用 自己的类加载器来完成的话,很可能就存在多个版本的 java.lang.Object 类,而且这些类之间是不兼 容的。通过代理模式,对于 Java 核心库的类的加载工作由引导类加载器来统一完成,保证了 Java 应用 所使用的都是同一个版本的 Java 核心库的类,是互相兼容的。
线程上下文类加载器
线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。类 java.lang.Thread 中的方 法 getContextClassLoader() 和 setContextClassLoader(ClassLoader cl) 用来获取和设置线程的上下文 类加载器。如果没有通过 setContextClassLoader(ClassLoader cl)方法进行设置的话,线程将继承其父 线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器。在线程中运行的 代码可以通过此类加载器来加载类和资源。
前面提到的类加载器的代理模式并不能解决 Java 应用开发中会遇到的类加载器的全部问题。Java 提供 了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。这些 SPI 的接口由 Java 核心库来提供,如 JAXP 的 SPI 接口 定义包含在 javax.xml.parsers 包中。这些 SPI 的实现代码很可能是作为 Java 应用所依赖的 jar 包被包 含进来,可以通过类路径(CLASSPATH)来找到,如实现了 JAXP SPI 的 Apache Xerces 所包含的 jar 包。SPI 接口中的代码经常需要加载具体的实现类。如 JAXP 中的 javax.xml.parsers.DocumentBuilderFactory 类中的 newInstance() 方法用来生成一个新的 DocumentBuilderFactory 的实例。这里的实例的真正的类是继承自 javax.xml.parsers.DocumentBuilderFactory,由 SPI 的实现所提供的。如在 Apache Xerces 中,实现 的类是 org.apache.xerces.jaxp.DocumentBuilderFactoryImpl。而问题在于,SPI 的接口是 Java 核心库的一部分,是由引导类加载器(extension classloader)来加载的;SPI 实现的 Java 类一般是 由系统类加载器(system classloader)来加载的。引导类加载器是无法找到 SPI 的实现类的,因为它只加 载 Java 的核心库。它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。也就是说, 类加载器的代理模式无法解决这个问题。 线程上下文类加载器正好解决了这个问题。如果不做任何的设 置,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上 下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用 到。
下面介绍另外一种加载类的方法:Class.forName。
Class.forName
Class.forName 是一个静态方法,同样可以用来加载类。该方法有两种形式:
Class.forName(String name, boolean initialize, ClassLoader loader)
Class.forName(String className)
第一种形式的参数 name 表示的是类的全名;initialize 表示是否初始化类;loader 表示加载时使用的 类加载器。第二种形式则相当于设置了参数 initialize 的值为 true,loader 的值为当前类的类加载器。 Class.forName 的一个很常见的用法是在加载数据库驱动的时候。如 Class.forName("org.apache.derby.jdbc.EmbeddedDriver").newInstance() 用来加载 Apache Derby 数据库的驱动。
在介绍完类加载器相关的基本概念之后,下面介绍如何开发自己的类加载器。
开发自己的类加载器
虽然在绝大多数情况下,系统默认提供的类加载器实现已经可以满足需求。但是在某些情况下,您还 是需要为应用开发出自己的类加载器。比如您的应用通过网络来传输 Java 类的字节代码,为了保证安全性,这些字节代码经过了加密处理。这个时候您就需要自己的类加载器来 从某个网络地址上读取加密后的字节代码,接着进行解密和验证,最后定义出要在 Java 虚拟机中运行的 类来。下面将通过两个具体的实例来说明类加载器的开发。 下面,详细的介绍自定义一个类加载器的过程。
一、首先,写出一个接口,然后用一个类实现该接口,该类作为测试类,即我们自定义 ClassLoader 要 加载的类。 接口:
/**
* 要加载类的接口,加载该接口的子类时,可以用接口引用,而不需要利用反射来实现。
*/
public interface InterfaceTest {
public void name();
public void age();
}
/
**
* 测试类,自定义类加载器去加载该类
*/
public class ClassTest implements InterfaceTest{
@Override
public void name() {
System.out.println("tao");
} @
Override
public void age() {
System.out.println("21");
}
}
二、自定义一个加密类,用来加密测试类的字节码文件。
到我们要加密的.class 文件的位置: E:\workspace.fu\ClassLoaderTest\bin\com\tao\test\ClassTest.class 加密后的.class 文件要存储的位置,这里将它直接放到 E 盘根目录:E:\ClassTest.class
/**
* 用加密算法生成要隐藏的字节码文件
*/
public class ClassEncrypt extends MyClassLoader{
public static void main(String[] args) throws IOException {
//要加密的字节码.class文件
String
srcPath="E:/workspace.fu/ClassLoaderTest/bin/com/tao/test/ClassTest.class";
//加密之后输出的字节码.class文件的位置
String destPath="E:/ClassTest.class";
FileInputStream fis=new FileInputStream(srcPath);
FileOutputStream ofs=new FileOutputStream(destPath);
cypher(fis, ofs);//加密
fis.close();
ofs.close();
} /
/简单的加密,用于测试。将所有二进制位取反,即0变成1,1变成0
private static void cypher(InputStream in,OutputStream out) throws
IOException{
int b=-1;
while((b=in.read())!=-1){
out.write(b^0xff);
}
}
}
运行该类,那么,我们就已经对 ClassTest.class 文件加密成功,打开 E 盘,可以发现根目录下已经有了一 个 ClassTest.class 文件。 可以将 E:\workspace.fu\ClassLoaderTest\bin\com\tao\test 的 ClassTest.class 文件删除,并且删除 ClassTest.java 文件和加密类 ClassEncrypt.java。 三、编写我们自己的类加载器,必须继承 ClassLoader,然后覆盖 findClass()方法。 ClassLoader 超类的 loadClass 方法用于将类的加载操作委托给父类加载器去进行,只有该类尚未加载并 且父类加载器也无法加载该类时,才调用 findClass()方法。 如果要实现该方法,必须做到以下几点:
(1)、为来自本地文件系统或者其他来源的类加载其字节码
(2)、调用 ClassLoader 超类的 defineClass()方法,向虚拟机提供字节码
/
**
* 自定义的类加载器
*/
public class MyClassLoader extends ClassLoader{
/**
* 因为类加载器是基于委托机制,所以我们只需要重写findClass方法。
* 它会自动向父类加载器委托,如果父类没有找到,就会再去调用我们重写的findClass方法加载
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
//需要加载的.class字节码的位置
String classPath="E:/ClassTest.class";
FileInputStream fis=new FileInputStream(classPath);
ByteArrayOutputStream bos=new ByteArrayOutputStream();
cypher(fis, bos);
fis.close();
byte[] bytes=bos.toByteArray();
return defineClass(bytes, 0, bytes.length);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} r
eturn super.findClass(name);
} /
/相应的字节码解密类,在加载E盘根目录下的被加密过的ClassTest.class字节码的时候,进行相应
的解密。
private static void cypher(InputStream in,OutputStream out) throws
IOException{
int b=-1;
while((b=in.read())!=-1){
out.write(b^0xff);
}
}
}
四、最后写一个测试类,测试我们的类加载器
public class Test {
public static void main(String[] args) throws ClassNotFoundException,
InstantiationException, IllegalAccessException {
Class clazz=new MyClassLoader().loadClass("com.tao.test.ClassTest");
//这就是我们接口的作用。如果没有接口,就需要利用反射来实现了。
InterfaceTest classTest=(InterfaceTest) clazz.newInstance();
classTest.name();
classTest.age();
}
}
对于运行在 Java EE? 容器中的 Web 应用来说,类加载器的实现方式与一般的 Java 应用有所不同。
不同的 Web 容器的实现方式也会有所不同。以 Apache Tomcat 来说,每个 Web 应用都有一个对应的类加载器实例。该类加载器也使用代理模式,所不同的是它是首先尝试去加载某个 类,如果找不到再代理给父类加载器。这与一般类加载器的顺序是相反的。这是 Java Servlet 规范中 的推荐做法,其目的是使得 Web 应用自己的类的优先级高于 Web 容器提供的类。这种代理模式的一个 例外是:Java 核心库的类是不在查找范围之内的。这也是为了保证 Java 核心库的类型安全。 绝大 多数情况下,Web 应用的开发人员不需要考虑与类加载器相关的细节。下面给出几条简单的原则:
每个 Web 应用自己的 Java 类文件和使用的库的 jar 包,分别放在 WEB-INF/classes 和 WEBINF/lib 目录下面。
多个应用共享的 Java 类文件和 jar 包,分别放在 Web 容器指定的由所有 Web 应用共享的目录下 面。 当出现找不到类的错误时,检查当前类的类加载器和当前线程的上下文类加载器是否正确。 在介绍完类加载器与 Web 容器的关系之后,下面介绍它与 OSGi 的关系。 类加载器与 OSGi OSGi 是 Java 上的动态模块系统。它为开发人员提供了面向服务和基于组件的运行环境,并提供标 准的方式用来管理软件的生命周期。OSGi 已经被实现和部署在很多产品上,在开源社区也得到了 广泛的支持。Eclipse 就是基于 OSGi 技术来构建的。 OSGi 中的每个模块(bundle)都包含 Java 包和类。模块可以声明它所依赖的需要导入 (import)的其它模块的 Java 包和类(通过 Import-Package),也可以声明导出(export)自己 的包和类,供其它模块使用(通过 Export-Package)。也就是说需要能够隐藏和共享一个模块中 的某些 Java 包和类。这是通过 OSGi 特有的类加载器机制来实现的*。OSGi 中的每个模块都有对应 的一个类加载器。它负责加载模块自己包含的 Java 包和类。当它需要加载 Java* 核心库的类时 (以 java 开头的包和类),它会代理给父类加载器(通常是启动类加载器)来完成。当它需要加 载所导入的 Java 类时,它会代理给导出此 Java 类的模块来完成加载。模块也可以显式的声 明某些 Java 包和类,必须由父类加载器来加载。只需要设置系统属性org.osgi.framework.bootdelegation 的值即可。** 假设有两个模块 bundleA 和 bundleB,它们都有自己对应的类加载器 classLoaderA 和 classLoaderB。在 bundleA 中包含类 com.bundleA.Sample,并且该类被声明为导出的,也就是 说可以被其它模块所使用的。bundleB 声明了导入 bundleA 提供的类 com.bundleA.Sample,并 包含一个类 com.bundleB.NewSample 继承自 com.bundleA.Sample。在 bundleB 启动的时候, 其类加载器 classLoaderB 需要加载类 com.bundleB.NewSample,进而需要加载类 com.bundleA.Sample。由于 bundleB 声明了类 com.bundleA.Sample 是导入的,classLoaderB 把加载类 com.bundleA.Sample 的工作代理给导出该类的 bundleA 的类加载器 classLoaderA。 classLoaderA 在其模块内部查找类 com.bundleA.Sample 并定义它,所得到的类 com.bundleA.Sample 实例就可以被所有声明导入了此类的模块使用。对于以 java 开头的类,都 是由父类加载器来加载的。如果声明了系统属性 org.osgi.framework.bootdelegation=com.example.core.*,那么对于包 com.example.core 中 的类,都是由父类加载器来完成的。 OSGi 模块的这种类加载器结构,使得一个类的不同版本可以共存在 Java 虚拟机中,带来了很大的 灵活性。不过它的这种不同,也会给开发人员带来一些麻烦,尤其当模块需要使用第三方提供的库 的时候。下面提供几条比较好的建议: 如果一个类库只有一个模块使用,把该类库的 jar 包放在模块中,在 Bundle-ClassPath 中指明即 可。 如果一个类库被多个模块共用,可以为这个类库单独的创建一个模块,把其它模块需要用到的 Java 包声明为导出的。其它模块声明导入这些类。 如果类库提供了 SPI 接口,并且利用线程上下文类加载器来加载 SPI 实现的 Java 类,有可能会找不 到 Java 类。如果出现了 NoClassDefFoundError 异常,首先检查当前线程的上下文类加载器是否 正确。通过 Thread.currentThread().getContextClassLoader() 就可以得到该类加载器。该类加载 器应该是该模块对应的类加载器。如果不是的话,可以首先通过 class.getClassLoader() 来得到模块对应的类加载器,再通过 Thread.currentThread().setContextClassLoader() 来设置当 前线程的上下文类加载器。 *总结* 类加载器是 Java 语言的一个创新。它使得动态安装和更新软件组件成为可能。本文详细介绍了 类加载器的相关话题,包括基本概念、代理模式、线程上下文类加载器、与 Web 容器和 OSGi 的 关系等。开发人员在遇到 ClassNotFoundException 和 NoClassDefFoundError 等异常的时候, 应该检查抛出异常的类的类加载器和当前线程的上下文类加载器,从中可以发现问题的所在。在开 发自己的类加载器的时候,需要注意与已有的类加载器组织结构的协调。