JVM探究
面试常见:
-
请你谈谈你对JVM的理解? java8虚拟机和之前的变化更新?
-
什么是OOM,什么是栈溢出StackOverFlowError? 怎么分析?
-
JVM的常用调优参数有哪些?
-
内存快照如何抓取,怎么分析Dump文件?
-
谈谈JVM中,类加载器你的认识?
Java内存区域详解
1、运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK. 1.8 和之前的版本略有不同,下面会介绍到。
JDK 1.8 之前:
JDK 1.8 :
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
1.1 程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
从上面的介绍中我们知道程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意: 程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.2 Java 虚拟机栈
- 栈内存,主管程序的运行,生命周期和线程同步;线程结束,栈内存也就是释放,对于栈来说,不存在垃圾回收问题,一旦线程结束,栈就Over!描述的是 Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
- 栈:先进后出 桶:后进先出 队列:先进先出( FIFO : First Input First Output )
- 栈内存中:8大基本数据类型+对象引用+实例的方法
- 栈运行原理:栈帧(局部变量表、操作数栈、动态链接、方法出口信息)
Java 虚拟机栈会出现两种错误:StackOverFlowError
和 OutOfMemoryError
。
StackOverFlowError
: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。OutOfMemoryError
: Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常异常。
Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
扩展:那么方法/函数如何调用?
Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:
- return 语句。
- 抛出异常。
不管哪种返回方式都会导致栈帧被弹出
1.3、本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
1.4、堆
- Java 虚拟机所管理的内存中最大的一块
- 一个JVM只有一个堆内存,堆内存的大小是可以调节的。
- Java 堆是所有线程共享的一块内存区域
- 在虚拟机启动时创建。
- 类加载器读取了类文件后,一般会把什么东西放到堆中? 类, 方法,常量,变量~,保存我们所有引用类型的真实对象;
- 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
1.4.1、新生区
类诞生和成长的地方,甚至死亡。
- Eden:所有的对象都在这个区域new出来
- From Survivor0:幸存区0
- To Survivor1:幸存区1
GC
- Eden区满了就触发轻GC,经过轻GC存活下来的就到了幸存者区。
- 幸存者区满了之后就意味着新生区满了,则触发重GC,经过重GC后存活下来的就到了老年区。
1.4.2、永久区
- 这个区域常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据,存储的是Java运行时的一些环境~ 这个区域不存在垃圾回收,关闭虚拟机就会释放内存
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
- OutOfMemoryError: GC Overhead Limit Exceeded :
当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。 - java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)
1.4.3、堆内存调优
调大堆内存空间解决OOM
public class Demo02 {
public static void main(String[] args) {
// 返回虚拟机示图使用的最大内存
long max = Runtime.getRuntime().maxMemory();//字节 1024*1024
// 返回Jvm的总内存
long total = Runtime.getRuntime().totalMemory();
System.out.println("max="+max+"字节\t"+(max/(double)1024/1024));
System.out.println("total="+total+"字节\t"+(total/(double)1024/1024));
//默认情况下:分配的总内存是电脑内存的1/4;而初始化的内存:1/64
}
}
/*
执行结果:
max=1881145344字节 1794.0
total=128974848字节 123.0
*/
修改堆内存空间步骤:
选择Edit Configuration;配置vm如下:
/*
max=1029177344字节 981.5
total=1029177344字节 981.5
Heap
PSYoungGen(新生区) total 305664K, used 15729K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
eden(eden区) space 262144K, 6% used [0x00000000eab00000,0x00000000eba5c420,0x00000000fab00000)
from(幸存者区) space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
to(幸存者区) space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
ParOldGen(老年区) total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
Metaspace(元空间) used 3250K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 351K, capacity 388K, committed 512K, reserved 1048576K
上面可以看出新生区+老年区=total(981)
可以看出来元空间不属于堆管理,是直接在本地内存中的,不在堆中分配内存空间。
所以元空间(永久区/方法区)是逻辑上存在,物理上不存在于堆。
*/
/**
* 出现OOM错误,你怎么解决?
* 1.先尝试把堆内存的空间调大看结果:-Xms1024m -Xmx1024m -XX:+PrintGCDetails
* 2.如果还错,分析内存,看一下那个地方出现了问题(专业工具)
*/
出现OOM错误的例子代码:
运行结果如下:
一开始是轻GC,4次之后发现清不了了,就来一次重Full GC,然后就又可以GC,然后GC不行了,就到Full GC,到最后Full GC也清不动了,堆内存满了,就报出OOM错误;所以这种情况可以选择调大堆内存空间,如果不报错了,说明就是堆内存空间不够导致的错误。如果调大都解决不了就可以使用工具分析错误原因。
使用工具JProfiler解决OOM
在一个项目中,突然出现了OOM故障,那么该如何排除 研究为什么出错~
- 能够看到代码第几行出错:dump+内存快照分析工具,MAT, Jprofiler
- Debug, 一行行分析代码!
扩大内存如果没有报错了,那么就是内存太小了;
MAT, Jprofiler作用
- 分析Dump内存文件,快速定位内存泄露;
- 获得堆中的数据
- 获得大的对象~
Jprofile使用
- 在idea中下载jprofile插件
- 联网下载jprofile客户端 ;
- 下载网址:https://www.ej-technologies.com/download/jprofiler/version_92
- idea设置中Tool里找到Jprofile,将下载好的客户端exe导入idea。
- idea中写代码测试一下
package demo;
import java.util.ArrayList;
public class Demo03 {
byte[] array=new byte[1*1024*1024];
public static void main(String[] args) {
ArrayList<Demo03> objects = new ArrayList<>();
int count=0;
try {
while (true) {
objects.add(new Demo03());
count = count + 1;
}
} catch (Exception e) {
// e.printStackTrace();
System.out.println("count:" + count);
e.printStackTrace();
}
}
}
可以看到try catch是捕获不到这个异常的
- 选择Edit Configuration;配置vm如下:
-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
- 运行程序后控制台看到有文件java_pid26176.hprof,打开类的文件位置(类右击选show in Explorer),往上找可以找到控制台看到的文件,双击那个文件可以打开jprofile客户端中;
- Biggest Objects–>看到ArrayList的Retained size占了最大值。所以肯定就是ArrayList出现问题了,但是还不知道哪行出现问题
- Thread Dump看每一条线程,比如main点击。可以看到这个线程下出现的错误代码是哪一行错了。可以看到他指出了main方法中的13行出现问题。
9.刚刚的Exception捕获不到异常,改成Error打印一下错误。
10.解决完问题之后,可以将dump出来的文件删掉了,dump之后出来了很多东西。
- -Xms设置初始化内存分配大小/164
- -Xmx设置最大分配内存,默以1/4
- -XX: +PrintGCDetails //打印GC垃圾回收信息
- -XX: +HeapDumpOnOutOfMemoryError //oom DUMP
- 如果出现其他错误,只有修改-XX的错误为你出现的错误,按照上面的步骤可以知道错误代码在哪一行。
1.5、方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(构造方法、接口定义)、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关
方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。
1.5.1 方法区和永久代的关系
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
1.5.2、常用参数
JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
下面是一些常用参数:
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
1.5.3、为什么将永久代(PermGen)替换为元空间(MetaSpace)呢?
- 整个永久代有一个 JVM
本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
- 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了
1.6、运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
1、JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
2、JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代 。
3、JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
1.7、直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
Java内存区域画图总结
JDK1.7前
JDK1.7
JDK1.8
HotSpot虚拟机对象创建过程
图解整个过程
内存分配的两种方式:(补充内容,需要掌握)
分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
图解对象的内存布局
对象的访问地位
-
句柄
-
直接指针
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
JVM
1.JVM的位置
图 JVM图解
2.JVM的体系结构
图 .java->.class的变化
垃圾产生的位置:
- 百分之99的JVM调优都是在堆中调优,Java栈、本地方法栈、程序计数器是不会有垃圾存在的。
- 方法区属于一个特殊的堆,栈用完就弹出去了,如果栈里有垃圾,那么程序估计就崩了,main方法执行,调用方法就是往栈里压东西,但是每用完一个就弹出去一个,如果其中有个垃圾,那么就会把栈堵死了,main方法就无法继续执行了。
3.类加载器
作用:
- 加载Class文件~ new Student();的时候创建实例,对象引用是在栈中,但是具体的数据是在堆中
下面的car实例的名字car1,car2,car3是在栈中的,他们都是对象引用,里面存放地址。但是最终他们是要指向堆中的数据,根据栈中地址去堆中找对应的数据。
图 类在经过Class Loader之后的变化
3.1、类加载器的引入
- class文件存在于本地磁盘上,在执行的时候加载到JVM中,根据这个文件实例可以实例化出N个一模一样的实例
- class文件加载到JVM中,被称为DNA元数据模板,存放在方法区中
- class文件–>JVM–>最终为元数据模板,此过程就要一个运输工具(类加载器Class Loader),扮演一个快递员的角色。
class文件被类加载器加载之后就进入了jvm中,类加载器加载初始化class,Class通过实例化就创建了一个个对象,实例要转化为Class可以通过getClass方法返回,要想变回加载时候,通过getClassLoader方法。
每new出来一个对象,JVM都会给他分配一个新的内存空间,创建对象的引用地址不一样,用来标识不同的内存空间,但是都是一个Class里面出来的。
3.2、类加载的过程
系统加载class类型的文件主要有三步:加载–>连接–>初始化;连接又分为了三部分:验证–>准备–>解析;
- 加载: 主要完成以下三件事:
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
- 连接
- 验证:
目的在于保证class文件的字节流中包含的信息符合虚拟机的要求,保证被加载类的正确性,不会危害虚拟机的安全。主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证 - 准备:
为类变量分配内存并且设置类变量初始值的阶段。注意一下几点:- 这时候进行内存分配的仅包含类变量(static),而不包括实例变量,(因为对象还没有创建),类变量还会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。
- 这里所设置的初始值“通常情况下”是数据类型默认的零值(如0,0L,null,false等),比如定义了:
public static int a=111
,那么a变量在准备阶段的初始值为0而不是111(初始阶段才会赋值)。特殊情况:比如给a变量加上了final关键字,那么准备阶段a的值就被赋值为111 - 这里不包括final修饰的static,因为final在编译的时候就已经分配了,准备阶段会显示初始化。
- 解析:
将常量池内的符号应用转换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
事实上,解析操作往往伴随着JVM在执行完初始化之后再操作。
- 验证:
- 初始化:
也是真正执行类中定义的 Java 程序代码(字节码),初始化阶段是执行初始化方法 ()方法的过程。- 当创建类的实例对象、调用类的静态变量(不是静态常量,常量会被加载到运行时常量池)、给类的静态变量赋值、调用类的静态方法四种情况下必须对类进行初始化
- 对类进行反射调用时,如果类没初始化,那么触发其初始化。
- 初始化一个类的时候该父类没有初始化,需要先触发该父类的初始化
- 虚拟机启动时,虚拟器先初始化包含main方法的类
- MethodHandle和VarHandle可以看作是轻量级的反射调用机制,而要想使用这2个调用, 就必须先使用findStaticVarHandle来初始化要调用的类。
- 补充,来自issue745」 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化
加载.class文件的方法:
1、从本地中直接加载
2、通过网络获取,典型场景:Web Applet
3、从zip压缩包中读取,成为日后jar,war格式的基础
4、运行时计算生成,使用最多的是:动态代理技术(proxy.newInstance() )
5、由其他文件生成,典型场景:JSP应用
6、从专有数据库中提取.class文件,比较少见
7、从加密文件中获取,典型的防Class文件被反编译的保护措施
3.3、类加载器的分类
JVM中内置了三个重要的ClassLoader,除了启动类加载器,其它类加载器均由Java实现且全部继承自java.lang.ClassLoader
:
- BootstrapClassLoader(启动类加载器):最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
- ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
- AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。
//Car类:
public class Car {
String name;
String type;
double price;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
}
public class ClassLoaderTest {
public static void main(String[] args) {
Car car = new Car();
Class<? extends Car> aClass = car.getClass();
ClassLoader classLoader = aClass.getClassLoader();
System.out.println(classLoader);
System.out.println(classLoader.getParent());
System.out.println(classLoader.getParent().getParent());
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader1 = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader1);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器(bootstrap)进行加载的。
ClassLoader classLoader2 = String.class.getClassLoader();
System.out.println(classLoader2);//null
}
}
/*
执行结果:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@1b6d3586
null
sun.misc.Launcher$AppClassLoader@18b4aac2
null
*/
4.双亲委派机制
每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
工作原理
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终到达顶层的启动类加载器。
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法加载此任务,子类加载器才会尝试自己去加载,这就是双亲委派机制。
优点: - 避免类的重复加载
- 保护线程安全,防止核心API被随意篡改
5.沙箱安全机制
Java安全模型的核心就是Java沙箱(sandbox) , 什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是**将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,**通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。 沙箱主要限制系统资源访问,那系统资源包括什么? CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。 所有的Java程序运行都可以指定沙箱,可以定制安全策略。
-
JDK1.0安全模型:
在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱Sandbox)机制。
图 JDK1.0安全模型
-
JDK1.1安全模型:
但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。
图 JDK1.1安全模型
- JDK1.2安全模型
在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。
图 JDK1.2安全模型
- JDK1.6(最新的安全模型)
当前最新的安全机制实现,则引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就具有了当前域的全部权限
图 JDK1.6安全模型
栈溢出:比如a方法中调用b方法,b方法中调用a,那么就会陷入死循环,会不断往栈里加东西,栈有一定内存空间,所以就会出现栈满溢出的情况。
例子
明明有main方法的存在,但是说找不到该方法,原因是他找到是原来的String类
自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
面试题
new和newInstance()的区别
new是强类型校验,可以调用任何构造方法,在使用new操作的时候,这个类可以没有被加载过
而Class类下的newInstance()是弱类型,只能调用无参数构造方法,如果没有默认构造方法,就会抛出InstantiationException异常
java编译与运行原理
【一、初识Java编译】
在开发我们的第一个Java程序之前,首先粗略的了解一下Java代码的编译和执行的整个过程。我们经常会看到Java工程里面有这两种后缀的文件:.java和.class。这两种文件分别代表Java的源代码和字节码文件,而其中的字节码文件就是java实现"Write Once,Run Anywhere"的关键。我们可以先来看看下面两幅图[1]。
Java编译器编译Java代码的流程如下:
JVM执行引擎完成Java字节码的执行:
从上两图可以看到,Java运行程序分两步走,第一步是源码编译成字节码,第二步是字节码编译成目标代码。这就和C、C++直接编译成与机器相关的目标代码不一样了。通过字节码这一中间环节,大家可以拿着编译成功的包发布到任一有JVM环境的机器上,再由JVM来实现到机器相关的最终目标代码的编译,从而做到"Write Once,Run Anywhere",而无需与具体运行平台绑定。所以我们通常所说的对代码进行编译,就是Java源代码编译成JVM字节码的过程。
【二、java与javac】
在上一节我们已经说过,%JAVA_HOME%\bin目录下有大量的Java工具可以使用,我们以后也要逐渐熟悉。那作为初接触java的我们,首先应该熟悉哪个呢?无疑是java与javac了。
IDE要用,可以极大的提高我们的生产效率,但是底层原理也一定要懂,不然都不知道IDE是怎么讲你的程序编译打包运行的,遇到问题的时候就不会解决了。很经常见到的一种情况是,有些人看到服务器上没有IDE就懵了,连怎么运行jar包程序都不会。
回归正传,我们就从java与javac入手,了解怎么在命令行中编译和运行一个java程序。首先,我们分别认识这两个命令是怎么用的。
【三、java命令】
作用:用于执行类或者执行jar文件。
在cmd控制台中输入java回车,我们可以得到java命令运行的格式如下:
【四、javac命令】
作用:用于编译.java文件。
在cmd控制台中输入javac回车,我们可以得到javac命令运行的格式如下:
组成沙箱的基本组件
- 字节码校验器(bytecode verifier) :
确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。 - 类裝载器(class loader) :
其中类装载器在3个方面对Java沙箱起作用 - 它防止恶意代码去干涉善意的代码;
- 它守护了被信任的类库边界;
- 它将代码归入保护域,确定了代码可以进行哪些操作。
虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成, 每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。 类装载器采用的机制是双亲委派模式。 1.从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用; 2.由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。 - 存取控制器(access controller) :
存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。 - 安全管理器(security manager) :
是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。 - 安全软件包(security package) :
java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括:- 安全提供者
- 消息摘要
- 数字签名
- 加密
- 鉴别
- 字节码校验:
类加载到jvm之后,会通过字节码校验器进行校验java语法,比如你写个输出语句没有分号,那么程序就会出现错误提示。
6.Native
- native :
凡是带了native关键字的,说明java的作用范围达不到了,回去调用底层c语言的库! - 会进入本地方法栈
- 调用本地方法本地接口 JNI (Java Native Interface)
- JNI作用:
开拓Java的使用,融合不同的编程语言为Java所用! - 最初: C、C++,Java诞生的时候C、C++横行,想要立足,必须要有调用C、C++的程序 ,它在内存区域中专门开辟了一块标记区域:
Native Method Stack,登记native方法. - 在最终执行的时候,加载本地方法库中的方法通过JNI , 例如:Java程序驱动打印机,管理系统,掌握即可,在企业级应用比较少
- private native void start0();
- //调用其他接口:Socket. . WebService~. .http~
Native Method Stack
它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。[本地库]
Native Interface本地接口
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序, Java在诞生的时候是C/C++横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了块区域处理标记为native的代码,它的具体做法是在Native Method Stack 中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍!
7.程序计数器(PC寄存器)
7.1、PC寄存器的介绍
-
JVM中的程序计数寄存器(Program Counter
Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息,CPU只有把数据装载到寄存器才能够运行。 -
这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
7.2、PC寄存器的作用
PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。
- 每个线程都有一个程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
- 就是一个指针,指向方法区中的方法字节码(用来存储指向像一条指令的地址,也即将要执行的指令代码),在执行引擎读取下一条指令,
- 是一个非常小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。(可以把PC寄存器看成代码的执行行数)
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
- 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。(因为程序计数器在各个线程之间互不影响)
为什么使用PC寄存器存储字节码指令地址?
- 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
- JVM的字节码解释就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
为什么设置线程私有
- 共享的话,第一个线程执行到的指令行数会被覆盖掉,当从线程2或者线程3切换回来后,不知道线程1执行到哪里了。
- 我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
- 由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。
- 这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
CPU时间片
CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
8.三种JVM
●Sun公司HotSpot Java Hotspot™ 64-Bit Server VM (build 25.181-b13,mixed mode) ●BEA JRockit ●IBM J9VM 我们学习都是: Hotspot
9.GC
图 GC的作用区
JVM在进行GC时,并不是对这三个区域统一回收。 大部分时候,回收都是新生代~ 堆中的三个区:
- 新生区
- Eden
- 幸存区(form,to)
- 老年区
GC两种类:
- 轻GC (普通的GC)
- 重GC (全局GC)
GC常见面试题目:
- JVM的内存模型和分区~详细到每个区放什么?
- 堆里面的分区有哪些?Eden From To 老年区,说说他们的特点?
- GC的算法有哪些? 标记清除法,标记压缩,复制算法,引用计数器
- 轻GC和重GC分别在什么时候发生?
图 JVM内存模型和分区
GC算法:
引用计数器:
假设对象A用了一次就标记1,用了2次就标记2,没用就标记0,给每个对象分配计数器,这个是一个成本,本身计数器就会有消耗;标记为0的就是没有用了,所以就将其清理。一般都用这种。
图 引用计数法 很少使用了
复制算法
理解幸存区的from和to:比如Eden被GC后Eden为空了,活的进入幸存区,如果上面的是from下面是to,这时候,to中的空间足够,所以from中的存活下来的就把活的放到to中,这时候from就并为空了,因为谁空谁是to,所以一开始上面的是from就变成to了。如果在from还能活下来,就会进入到老年区。
图 复制算法大致图
图 复制算法图解
- 好处:没有内存的碎片~
- 坏处:浪费了内存空间~ :多了一半空间永远是空to。假设对象100%存活(极端情况)
- 复制算法最佳使用场景:对象存活度较低的时候;新生区~ 持续更新~
标记清除算法
扫描这些对象,对活着的对象进行标记,对没有标记的对象进行清除。
- 优点:不需要额外的空间
- 缺点:两次扫描,严重浪费时间,会产生内存碎片(标记压缩解决)
标记压缩算法
多几次标记清除然后再压缩,这样可能效率高点。
总结
- 内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂大)
- 内存整齐度:复制算法=标记压缩算法>标记清除算法
- 内存利用率:标记压缩算法=标记清除算法>复制算法
思考:难道没有最优算法?
答案:没有最好的算法,只有最合适的算法。---->GC:分代收集算法
年轻代:
- 存活率低
- 复制或算法最合适
老年代:
- 区域大,存活率高
- 标记清除(内存碎片不是太多)+标记压缩(内存碎片多了最后使用一次)混合实现
JVM调优就可以在这个地方使用。