文章目录 1.JVM的位置 1.1 JDK 、JRE 、JVM 的关系 1.1.1 JDK 1.1.2 JRE 1.1.3JVM 1.2JDK、JRE、JVM三者的联系与区别 2.JVM 的体系结构
文章目录
- 1.JVM的位置
- 1.1 JDK 、JRE 、JVM 的关系
- 1.1.1 JDK
- 1.1.2 JRE
- 1.1.3JVM
- 1.2JDK、JRE、JVM三者的联系与区别
- 2.JVM 的体系结构
- 3.类加载器
- 3.1 new关键字 与 实例化过程
- 3.2类加载器
- 4.双亲委派机制
- 4.1委派机制的流程图
- 4.2双亲委派机制的作用
- 5.沙箱安全机制【了解】
- 5.1组成沙箱的基本组件
- 6.native关键字
- 6.1Native Interface本地接口
- 7.程序计数器
- 8.方法区
- 9.栈
- 10.三种JVM
- 11.堆
- 12.新生区、老年区、永久区
- 12.1新生区 (新生代)
- 12.2老年区(老年代)
- 12.3永久区(永久代)
- 12.4 堆内存调优
- 13.java虚拟机运行时数据区小结【重点】
- 13.1虚拟机运行时数据区各分区的介绍
- 13.2对象的创建(new一个对象的过程)
- 13.3对象在堆内存中的存储布局
- 13.4对象的访问定位
- 13.5类加载过程
- 13.6综上:
- 14.使用JPofiler工具分析OOM原因
- 15.GC垃圾回收
- 15.1GC作用区
- 15.2GC的概念
- 15.3GC算法
- 15.3.1引用计数法
- 15.3.2复制算法
- 15.3.3根搜索算法
- 15.3.4标记清拆算法
- 15.3.5标记压缩
- 15.4总结:
面试常见一些问题:
- 请你谈谈你对JVM的理解? java8虚拟机和之前的变化更新?
- 什么是OOM(内存溢出),什么是栈溢出StackOverFlowError? 怎么分析?
- JVM的常用调优参数有哪些?
- 内存快照如何抓取,怎么分析Dump文件?
- 谈谈JVM中,类加载器你的认识
1.JVM的位置
- jvm就是一个软件,操作系统也就是个软件
1.1 JDK 、JRE 、JVM 的关系
- JDK是 Java 语言的软件开发工具包(SDK)。在JDK的安装目录下有一个jre目录,里面有两个文件夹bin和lib,在这里可以认为bin里的就是jvm,lib中则是jvm工作所需要的类库,而jvm和 lib合起来就称为jre。
1.1.1 JDK
- JDK(Java Development Kit) 是整个JAVA的核心,包括了Java运行环境(Java Runtime Envirnment),一堆Java工具(javac/java/jdb等)和Java基础的类库(即Java API 包括rt.jar)
- JDK是java开发工具包,基本上每个学java的人都会先在机器 上装一个JDK,那他都包含哪几部分呢?在目录下面有 六个文件夹、一个src类库源码压缩包、和其他几个声明文件。其中,真正在运行java时起作用的 是以下四个文件夹:bin、include、lib、 jre。有这样一个关系,JDK包含JRE,而JRE包 含JVM。
include:java和JVM交互用的头文件
lib:类库
jre:java运行环境
(注意:这里的bin、lib文件夹和jre里的bin、lib是 不同的)
- 总的来说JDK是用于java程序的开发,而jre则是只能运行class而没有编译的功能。
1.1.2 JRE
- JRE(Java Runtime Environment,Java运行环境),包含JVM标准实现及Java核心类库。JRE是Java运行环境,并不是一个开发环境,所以没有包含任何开发工具(如编译器和调试器)
- JRE是指java运行环境。只有JVM还不能完成class的 执行,因为在解释class的时候JVM需要调用解释所需要的类库lib。 (jre里有运行.class的java.exe)
- JRE ( Java Runtime Environment ),是运行 Java 程序必不可少的(除非用其他一些编译环境编译成.exe可执行文件……),JRE的地位就象一台PC机一样,我们写好的Win64应用程序需要操作系统帮我们运行,同样的,我们编写的Java程序也必须要JRE才能运行。
1.1.3JVM
- JVM(Java Virtual Machine),即java虚拟机, java运行时的环境,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。针对java用户,也就是拥有可运行的.class文件包(jar或者war)的用户。里面主要包含了jvm和java运行时基本类库(rt.jar)。
- rt.jar可以简单粗暴地理解为:它就是java源码编译成的jar包。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。
1.2JDK、JRE、JVM三者的联系与区别
- 三者联系:
JVM不能单独搞定class的执行,解释class的时候JVM需要调用解释所需要的类库lib。在JDK下面的的jre目录里面有两个文件夹bin和lib,在这里可以认为bin里的就是jvm,lib中则是jvm工作所需要的类库,而jvm和 lib和起来就称为jre。JVM+Lib=JRE。总体来说就是,我们利用JDK(调用JAVA API)开发了属于我们自己的JAVA程序后,通过JDK中的编译程序(javac)将我们的文本java文件编译成JAVA字节码,在JRE上运行这些JAVA字节码,JVM解析这些字节码,映射到CPU指令集或OS的系统调用。 - 三者区别:
- JDK和JRE区别:在bin文件夹下会发现,JDK有javac.exe,而JRE里面没有,javac指令是用来将java文件编译成class文件的,这是开发者需要的,而用户(只需要运行的人)是不需要的。JDK还有jar.exe, javadoc.exe等等用于开发的可执行指令文件。这也证实了一个是开发环境,一个是运行环境。
- JRE和JVM区别:有JVM并不代表就可以执行class了,JVM执行.class还需要JRE下的lib类库的支持,尤其是rt.jar。
2.JVM 的体系结构
3.类加载器
3.1 new关键字 与 实例化过程
- 一个类是抽象的(一个模板),而对象是具体的(类的实例化),当使用new ,实例化后,实例化对象的名字存放在栈里,而对象的数据存放在堆里,这两者之间的联系是:栈里的对象名通过内存地址查找到存放在堆里的数据
public class Car {
public static void main(String[] args) {
Car car1 = new Car();
Car car2 = new Car();
Car car3 = new Car();
System.out.println(car1.hashCode()); //1163157884
System.out.println(car2.hashCode()); //1956725890
System.out.println(car3.hashCode()); //356573597
//反射获取类对象
Class<? extends Car> aClass1 = car1.getClass();
Class<? extends Car> aClass2 = car2.getClass();
Class<? extends Car> aClass3 = car3.getClass();
System.out.println(aClass1.hashCode()); //460141958
System.out.println(aClass2.hashCode()); //460141958
System.out.println(aClass3.hashCode()); //460141958
// 一个类的类对象只有一个,而实例化对象有多个
}
}
3.2类加载器
- 类加载器的作用是加载Class文件
- 类加载器分好几类(有等级):
- 虚拟机自带的加载器
- 启动类(根)加载器 (c++编写)
- 扩展类加载器
- 应用程序(系统类)加载器
public class Car {
public static void main(String[] args) {
Car car1 = new Car();
//反射获取类对象
Class<? extends Car> aClass1 = car1.getClass();
//获取类加载器
ClassLoader classLoader = aClass1.getClassLoader();
System.out.println(classLoader); //输出显示的为:AppClassLoader 应用程序加载器 ,(在jre\lib\rt.jar!\java\lang\ClassLoader.class 有这样一个抽象类,实现这个类的都是自定义的类加载器)
System.out.println(classLoader.getParent()); //输出为 ExtClassLoader 扩展类加载器 它存在于 jre1.8.0_144\lib\ext
System.out.println(classLoader.getParent().getParent());
//这里输出为null,有两种情况, 1是不存在 2是java程序获取不到, 这个应该是启动类(根)加载器,它存在于rt.jar中 (jre1.8.0_144\lib\rt.jar),根加载器是c++写的,所以java访问不到
}
}
4.双亲委派机制
- 双亲委派机制是为了保证安全的,学习双亲委派机制之前要先学习ClassLoader类加载器还有Java的基本知识
- 我们在IDE中编写的Java源代码被编译器编译成**.class**的字节码文件。然后由我们的ClassLoader负责将这些class文件加载到JVM中去执行。
- JVM中提供了三层的ClassLoader:
- Bootstrap classLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。
- ExtClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。
- AppClassLoader:主要负责加载应用程序的主函数类
- 先看一下源码
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查这个classsh是否已经加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// c==null表示没有加载,如果有父类的加载器则让父类加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//如果父类的加载器为空 则说明递归到bootStrapClassloader了
//bootStrapClassloader比较特殊无法通过get获取
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {}
if (c == null) {
//如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4.1委派机制的流程图
从上图中我可以看出,当一个Hello.class这样的文件要被加载时。不考虑我们自定义类加载器,首先会在AppClassLoader中检查是否加载过,如果有那就无需再加载了(我们自己写的这个类就不会加载)。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类中同理会先检查自己是否已经加载过,如果没有再往上。注意这个过程,直到到达Bootstrap classLoader之前,都是没有哪个加载器自己选择加载的。如果父加载器无法加载,会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException。
4.2双亲委派机制的作用
- 保证(系统级别)核心.class文件不能被篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。这样保证了Class执行安全。
- 通俗的说,如果有人想替换系统级别的类:String.java。篡改它的实现,但是在这种机制下这些系统的类已经被Bootstrap classLoader加载过了,所以并不会再去加载,从一定程度上防止了危险代码的植入。
public class String {
//双亲委派机制:为了保证安全
public String toString(){
return "hello";
}
public static void main(String[] args) {
String s = new String();
System.out.println(s.toString());
}
/*
1.类加载器收到类加载的请求
2.将这个请求向上委托给父类加载器去完成,一直向上委托,直到启动类加载器
3.启动加载器检查是否能够加载当前这个类,能加载就结束, 使用当前的加载器,否则, 抛出异常,通知子加载器进行加载
4.重复步骤3
如此处,就是我们自己写的一个java.lang.String , 而在Bootstrap classLoader已经加载了这个类(rt.jar包中有这个类,)所以我们自己写的这个类就不会被加载,运行报ClassNotFoundException异常
*/
}
5.沙箱安全机制【了解】
- Java安全模型的核心就是Java沙箱(sandbox) ,
- 什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。
- 沙箱主要限制系统资源访问,那系统资源包括什么? CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
- 所有的Java程序运行都可以指定沙箱,可以定制安全策略。
- 在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱(Sandbox)机制。如下图所示JDK1.0安全模型
- 但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。如下图所示JDK1.1安全模型
- 在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示
- 当前最新的安全机制实现,则引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示最新的安全模型(jdk 1.6)
5.1组成沙箱的基本组件
- 字节码校验器(bytecode verifier):确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。
- 类装载器(class loader):其中类装载器在3个方面对Java沙箱起作用
- 它防止恶意代码去干涉善意的代码;(双亲委派机制)
- 它守护了被信任的类库边界;(双亲委派机制)
- 它将代码归入保护域,确定了代码可以进行哪些操作。(沙箱机制)
- 虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。
- 类装载器采用的机制是双亲委派模式。
- 从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用;
- 由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。
- 存取控制器(access controller):存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。
- 安全管理器(security manager):是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。
- 安全软件包(security package):java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括:
- 安全提供者
- 消息摘要
- 数字签名
- 加密
- 鉴别
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程序驱动打印机,管理系统,掌握即可,在企业级应用比较少
6.1Native 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寄存器,Program Counter Register
- 每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码 (用来存储指向像
一条指令的地址, 也即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计
8.方法区
- 方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;
- 静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关
- 创建对象内存分析
9.栈
- 栈:先进后出 一种数据结构
- 队列:先进先出( FIFO : First Input First Output )
- 想一下main方法为啥是第一个执行,最后一个结束,方法的调用就是勇敢栈来实现的
- 栈:又叫栈内存,主管程序的运行,栈的生命周期和线程同步;线程结束,栈内存也就是释放,对于栈来说,不存在垃圾回收问题,一旦线程结束,栈也就没了(栈对线程来说是私有的)
- 栈里面能存放的是:8大基本类型+对象引用+实例的方法
- 栈运行原理:栈帧 ,栈满了就报StackOverflowError (下面这个图就是方法在栈里的更详细的表现)
- 栈 堆 方法区交互: (和之前的创建对象内存分析是一样的)
10.三种JVM
- Sun公司HotSpot Java Hotspot™ 64-Bit Server VM (build 25.181-b13,mixed mode)
- BEA JRockit
- IBM J9VM
我们学习都是: Hotspot
11.堆
- 堆(Heap),一个JVM只有一个堆内存,堆内存的大小是可以调节的。
- 类加载器读取了类文件后,一般会把什么东西放到堆中? :就是存放对象实例
- 堆内存中还要细分为三个区域:
- 新生区(伊甸园区) Young(New)
- 它又可以分为伊甸园,幸存0,1区(幸存区是一个动态的概念,是分为from与to)
- 养老区old
- 永久区Perm (在JDK8以后,永久存储区改了个名字(元空间))
- 永久存储区里存放的都是Java自带的 例如lang包中的类 如果不存在这些,Java就跑不起来了
- GC垃圾回收,主要是在伊甸园区和养老区
- 假设内存满了,OOM,堆内存不够! java.lang.OutOfMemoryError:Java heap space
12.新生区、老年区、永久区
12.1新生区 (新生代)
- 新生区:这是类 诞生和成长的地方,甚至死亡(如果在新生区没有被垃圾回收器干掉,就会到老年区,但是99%的情况下,一个类活不到那么久)
- 新生区又分为:
- 伊甸园区 :
- 幸存者0区
- 幸存者1区
12.2老年区(老年代)
- 这没啥说的,就经过重GC(FUll GC)依然存活的对象会到老年区中
12.3永久区(永久代)
- 永久区的各种名字:
- jdk1.6之前:永久代,常量池是在方法区中;
- jdk1.7: 永久代,但是慢慢的退化了,当时正在去永久代,常量池在堆中
- jdk1.8之后:无永久代,常量池在元空间 (被称为元空间)
- 这个区域常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据,存储的是Java运行时的一些环境或类信息
- 这个区域不存在垃圾回收,关闭虚拟机就会释放这个区域的内存
- 元空间:逻辑上存在于堆中,物理上不存在于堆中 (因为存储在本地磁盘内) 所以最后从物理内存大小的角度来看,元空间并不算在JVM虚拟机内存中 (下面会用代码来验证)
public class Demo02 {
public static void main(String[] args) {
//返回虚拟机试图使用的最大内存
long max = Runtime.getRuntime().maxMemory();
//返回虚拟机(VM)初始时总内存
long total = Runtime.getRuntime().totalMemory();
System.out.println("max=" +max+"字节 " + (max/(double)1024/1024) + "MB");
System.out.println("total=" +total+"字节 " + (total/(double)1024/1024) + "MB");
//输出max=1780482048字节 1698.0MB total=120586240字节 115.0MB
//而我的电脑运行内存是7.7G, 所以默认情况下:虚拟机分配的最大内存是电脑内存的1/4,
//而虚拟机初始化时的内存是电脑的1/64
}
}
12.4 堆内存调优
- 调之前:
- 调优 :-Xms1024m -Xmx1024m -XX:+PrintGCDetails (将最大内存与初始化内存都设置为1024MB,并打印信息)
- 调优后
13.java虚拟机运行时数据区小结【重点】
13.1虚拟机运行时数据区各分区的介绍
- 如上图,虚拟机栈、本地方法栈、程序计数器、本地方法栈是线程私有的,方法区、堆、执行引擎、本地库接口是线程共享的
- 虚拟机栈(栈):
- 描述的是java方法执行的内存模型:每个方法执行的时候,java虚拟机都会同步的创建一个栈帧用于存储局部变量表、操作树栈、动态连接、方法出口等信息。
- 我们在说栈的时候,通常就是指这里的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。
- 局部变量表存放了编译期可知的各种java虚拟机基本数据类型(八大基本数据类型)、对象引用(reference类型,它并不等同于对象本身,可能是 一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
- 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占两个,其余的数据类型只占一个。
- 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
- java堆(堆):
- Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
- 此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。
- 方法区:
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
- 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 运行时常量池:
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
13.2对象的创建(new一个对象的过程)
- 第一步:当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
- 第二步:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。目前常用的有两种方式,选择哪种分配方式由Java堆是否规整决定,是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定:
- 指针碰撞(Bump the Pointer):假设Java堆的内存是绝对规整的,所有用过的内存都放一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
- 空闲列表(Free List):如果Java堆中的内存并不是规整的,已使用的内存和空间的内存是相互交错的,虚拟机必须维护一个空闲列表,记录上哪些内存块是可用的,在分配时候从列表中找到一块足够大的空间划分给对象使用。
- 除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:
- 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
- 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
- 第三步:内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
- 第四步:接下来,Java虚拟机还要对对象进行必要的设置(对对象头进行设置),例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。
- 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始:
- 第五步:但是从Java程序的视角看来,对象创建才刚刚开始——构造函数,即Class文件中的< init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行< init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
- 第六步:在线程栈中新建对象引用,并指向堆中刚刚新建的对象实例。
13.3对象在堆内存中的存储布局
- 对象在堆内存中的存储布局可以划分为三个部分:
- 对象头(Header)(它又包括以下两类信息):
- 用于存储对象自身的运行时数据:
如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等 - 类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
- 实例数据(Instance Data):
- 对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
- 对齐填充(Padding)
- 这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
13.4对象的访问定位
- 创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式主要有使用句柄和直接指针两种:
- 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图2-2所示。
- 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图2-3所示。
- 使用句柄和直接指针两种方式的优劣:
- 这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
- 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书(深入理解java虚拟机)讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。
13.5类加载过程
- 在13.2对象的创建(new一个对象的过程)中里面说当new一个对象时,会先在方法区中的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
- 下面看一下类加载过程:
- Java是使用双亲委派机制来进行类的加载的,所以在描述类加载过程前,我们先看一下它的工作过程:
- 双亲委托模型的工作过程是:
- 如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。
- 使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。(保证安全)
- 第一步加载:
- 由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的java.lang.Class对象实例
- 第二步验证:
- 格式验证:验证是否符合class文件规范
- 语义验证:检查一个被标记为final的类型是否包含子类;检查一个类中的final方法是否被子类进行重写;确保父类和子类之间没有不兼容的一些方法声明(比如方法签名相同,但方法的返回值不同)
- 操作验证:在操作数栈中的数据必须进行正确的操作,对常量池中的各种符号引用执行验证(通常在解析阶段执行,检查是否可以通过符号引用中描述的全限定名定位到指定类型上,以及类成员信息的访问修饰符是否允许访问等)
- 第三步准备:
- 为类中的所有静态变量分配内存空间,并为其设置一个初始值(由于还没有产生对象,实例变量不在此操作范围内)被final修饰的static变量(常量),会直接赋值;
- 第四步解析:
- 解析阶段是java虚拟机将常量池中的符号引用转为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法)的过程,这个可以在初始化之后再执行。
- 解析需要静态绑定的内容。 // 所有不会被重写的方法和域都会被静态绑定
- 以上2、3、4三个阶段又合称为链接阶段,链接阶段要做的是将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中。
- 第五步初始化(先父后子):
- 为静态变量赋值
- 执行static代码块 (注意:static代码块只有jvm能够调用,如果是多线程需要同时初始化一个类,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。)
- 因为子类存在对父类的依赖,所以**类的加载顺序是先加载父类后加载子类,初始化也一样。**不过,父类初始化时,子类静态变量的值也是有的,是默认值。
- 最终,方法区会存储当前类的类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句和静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用。
13.6综上:
- 综上所述,创建对象可简单的总结为下:
- 1、在堆区分配对象需要的内存:分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量
- 2、对所有实例变量赋默认值:将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值
- 3、执行实例初始化代码:初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法
- 4、如果有类似于Child c = new Child()形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它
- 需要注意的是,每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问
- 补充:
- 通过实例引用调用实例方法的时候,先从方法区中对象的实际类型信息找,找不到的话再去父类类型信息中找。
- 如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要经过很多次查找。这时候大多系统会采用一种称为虚方法表的方法来优化调用的效率。
- 所谓虚方法表,就是在类加载的时候,为每个类创建一个表,这个表包括该类的对象所有动态绑定的方法及其地址,包括父类的方法,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。
14.使用JPofiler工具分析OOM原因
- 在idea中下载jprofile插件
- 联网下载jprofile客户端
- 在idea中VM参数中写参数 -Xms1m -Xmx8m -XX: +HeapDumpOnOutOfMemoryError
- 运行程序后在jprofile客户端中打开找到错误 告诉哪个位置报错
- 命令参数详解
// -Xms设置初始化内存分配大小/164
// -Xmx设置最大分配内存,默以1/4
// -XX: +PrintGCDetails // 打印GC垃圾回收信息
// -XX: +HeapDumpOnOutOfMemoryError //oom DUMP
15.GC垃圾回收
15.1GC作用区
15.2GC的概念
- GC:Garbage Collection 垃圾收集。这里所谓的垃圾指的是在系统运行过程当中所产生的一些无用的对象,这些对象占据着一定的内存空间,如果长期不被释放,可能导致OOM。
- 在C/C++里是由程序猿自己去申请、管理和释放内存空间,因此没有GC的概念。而在Java中,后台专门有一个专门用于垃圾回收的线程来进行监控、扫描,自动将一些无用的内存进行释放,这就是垃圾收集的一个基本思想,目的在于防止由程序猿引入的人为的内存泄露。
- 事实上,GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC需要完成的3件事情:哪些内存需要回收?什么时候回收?如何回收?
- 内存区域中的程序计数器、虚拟机栈、本地方法栈这3个区域随着线程而生,线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。在这几个区域不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。
- 而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC关注的也是这部分内存,后面的文章中如果涉及到“内存”分配与回收也仅指着一部分内存。
- GC分为两类:轻GC(普通GC),重GC(全局GC)
15.3GC算法
- 在这只说一下标记清除法,标记压缩,复制算法,引用计数法
15.3.1引用计数法
- 首先给每一个对象分配一个计数器,用来记录这个对象用来多少次,计数器本身也会有消耗
- 如果一个对象的计数器记录的值为0,当GC启动时,它就会被清理
- 主流的java虚拟机并没有选用引用计数算法来管理内存,其中最主要的原因是:它很难解决对象之间相互循环引用的问题。效率不高
15.3.2复制算法
- 好处:没有内存的碎片
- 坏处:浪费了内存空间 :多了一半空间永远是空to。假设对象100%存活(在这个极端情况下,需要将所有地址都要重新做一遍,成本太高)
- 复制算法最佳使用场景:对象存活度较低的时候(比如:新生区)
15.3.3根搜索算法
- 根搜索算法的概念:
- 由于引用计数算法的缺陷,所以JVM一般会采用一种新的算法,叫做根搜索算法。它的处理方式就是,设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。
- 如上图所示,ObjectD和ObjectE是互相关联的,但是由于GC roots到这两个对象不可达,所以最终D和E还是会被当做GC的对象,上图若是采用引用计数法,则A-E五个对象都不会被回收。
- 可达性分析:
- 我们刚刚提到,设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。我们在后面介绍标记-清理算法/标记整理算法时,也会一直强调从根节点开始,对所有可达对象做一次标记,那什么叫做可达呢?这里解释如下:
- 可达性分析:
- 从根(GC Roots)的对象作为起始点,开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连(用图论的概念来讲,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
- 根(GC Roots):
- 说到GC roots(GC根),在JAVA语言中,可以当做GC roots的对象有以下几种:
- 1、栈(栈帧中的本地变量表)中引用的对象。
- 2、方法区中的静态成员。
- 3、方法区中的常量引用的对象(全局变量)
- 4、本地方法栈中JNI(一般说的Native方法)引用的对象。
- 注:第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。
- 在根搜索算法的基础上,现代虚拟机的实现当中,垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。这三种算法都扩充了根搜索算法,不过它们理解起来还是非常好理解的。
15.3.4标记清拆算法
- 标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后,在清除阶段,清除所有未被标记的对象。
- 详解:它的做法是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
- 标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
- 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
- 也就是说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
- 优点:不需要额外的空间
- 缺点:
- 首先,它的缺点就是效率比较低(递归与全堆对象遍历),导致stop the world的时间比较长,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?
- 第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
15.3.5标记压缩
- 与标记清除相比多了一个压缩的过程
- 标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
- 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
- 上图中可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
- 优点:标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
- 缺点:但是,标记/整理算法唯一的缺点就是效率也不高。 不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
15.4总结:
- 内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂度)
- 内存整齐度:复制算法=标记压缩算法>标记清除算法
- 内存利用率:标记压缩算法=标记清除算法>复制算法
没有最好的算法,只有最合适的算法— GC:分代收集算法
- 年轻代:
- 存活率低
- 适用复制算法!
- 老年代:
- 区域大,存活率高
- 适用标记清除(内存碎片不是太多) +标记压缩混合实现