初识Java—(二) Java的运行机制
Java语言是一种特殊的高级语言,它既具有解释型语言的特性,也具有编译型语言的特征,因为Java程序要经过先编译,后解释两个步骤。
计算机高级语言按程序的执行方式可以分为编译型和解释型两种。
编译型语言是指使用专门的编译器,针对特定的平台(操作系统)将某种高级语言源代码一次性“翻译”成可被执行的机器码,并包装成该平台所能识别的可执行性程序的格式,这个转换过程成为编译。编译生成的可执行程序可以脱离开开发环境,在特定的平台上独立运行。
举例:First.java javac.exe First.class
解释型语言是指使用专门的解释器对源程序逐行解释成特定平台的机器码并立即执行的语言。解释型语言通常不会进行整体性的编译,解释型语言相当于把编译型语言中的编译和解释过程混合到一起同时完成。
Java语言比较特殊,由Java语言编写的程序需要经过编译步骤,但这个编译步骤并不会生成特定平台的机器码,而是生成一种与平台无关的字节码(*.class)。这种字节码不是可执行的,必须使用Java解释器来解释执行。如下图所示:
运行流程
Java语言中负责解释执行字节码文件(*.class)的是Java虚拟机,即JVM(Java Virtual Machine)。JVM是可运行Java字节码文件的虚拟计算。JVM是一个抽象的计算机,和实际的计算机一样,它具有指令集并使用不同的存储区域。它负责执行指令,还要管理数据、内存和寄存器。
Java程序是如何运行的
JVM是Java的运行时虚拟机,所有的Java程序都是在JVM沙箱中运行,每个Java程序就是一个独立的JVM进程。
谈到Java程序是如何运行的,首先需要理解的肯定是JVM是如何运行的,什么是JVM;要理解我们编写的Java程序,运行起来以后到底是什么样子,本质上就是弄清楚JVM是什么样子。
Java程序的代码是什么样的
Java诞生之初最大的卖点就是编写的代码跨平台可移植性,实现这种可移植性,是因为Java通过平台特定的虚拟机,运行中间的字节码,而不是直接编译成本地二进制代码实现,中间字节码也就是java文件编译后生成的.class文件,Jar包的话,实际上只是一系列.class文件的集合。
编写Java程序,首先需要一个入口点,在运行的时候通过指定MainClass来指定入口点,代码层面主类必须实现一个静态的main函数,运行时虚拟机会从MainClass.main开始执行指令,其他的逻辑只是import和函数调用了。
SDK自带的javac命令,负责将我们编程的Java代码,也就是.java文件,编译成平台无关的字节码;字节码可以在任何操作系统平台上,通过平台对应的JVM执行;JVM执行的时候,运行字节码,根据自己的平台特性,将字节码转换成平台相关的二进制码运行。
javac编译器运行的过程大致分为:词法分析(Token流)、语法分析(语法树)、语义分析(注解语法树),还有代码生成器,根据注解语法树,生成字节码,
语义分析阶段,编译器会做一些操作,将人类友好的代码,做一些处理,转换成更符合机器执行机制的代码,例如全局变量,魔法变量,依赖注入,注解这些魔法机制。大致分为以下步骤:
- 给类添加默认构造函数
- 处理注解
- 检查语义的合法性并进行逻辑判断
- 数据流分析
- 对语法树进行语义分析(变量自动转换并去掉语法糖)
JVM是什么
JVM = 类加载器 classloader + 执行引擎 execution engine + 运行时数据区域 runtime data area
JVM就是运行编译好字节码的虚拟机,不同的操作系统和平台上,虚拟机将平台无关的字节码,编译成特定平台的指令去执行。我觉得,JVM首先是一个独立运行在操作系统上的进程。执行java命令运行程序的时候,会启动一个进程,每个独立的程序就运行在一个独立的JVM进程里。JVM负责执行字节码,从而实现程序要完成的所有功能。
JVM主要由三部分组成:类加载器、运行时数据区和执行引擎。类加载器加载编译好的.class文件,将所有类结构和方法变量放入运行时数据区,初始化之后,将程序的执行交给执行引擎;JIT编译器,负责将字节码编译成平台特定的二进制码,调用本地接口库。垃圾回收器作为执行引擎的一部分,负责维护运行时数据区中可变的应用程序内存空间。
类加载器(ClassLoader)
类加载器将类加载到内存,并管理类的生命周期,知道将类从内存中卸载结束生命周期。
系统提供了三种类加载器,分别用于不同类的加载:
- 启动类加载器(Bootstrap ClassLoader),该加载器会将<JAVA_HOME>lib目录下能被虚拟机识别的类加载到内存中,也就是系统类
- 扩展类加载器(Extension ClassLoader),该加载器会将<JAVA_HOME>libext目录下的类库加载到内存
- 应用程序类加载器(Application ClassLoader),该加载器负责加载用户路径上所指定的类库。
运行时数据区(Runtime Data Area)
运行时数据区,是JVM运行时,在内存中分配的空间。
运行时数据区,被分为五个不同的结构:
- Java虚拟机栈(Java Stacks): 也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。
- 本地方法栈(Native Method Memory): 登记的native方法,执行引擎执行时加载
- 程序寄存器(PC Registers): 当前线程所执行字节码的指针,存储每个线程下一步要执行的字节码JVM指令。
- Java堆(Heap Memory): 应用的对象和数据都是存在这个区域,这块区域也是线程共享的,也是gc 主要的回收区,一个 JVM 实例只存在一个堆类存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,以方便执行器执行。
- 方法区(Method Area): 所有定义的方法的信息都保存在该区域,此区域属于共享区间。静态变量+常量+类信息+运行时常量池存在方法区中,实例变量存在堆内存中。
其中的程序寄存器、Java虚拟机栈是按照线程分配的,每个线程都有自己私有的独立空间。
运行的方法和运行期数据,以栈帧的形式存储在运行时JVM虚拟机栈中,栈帧中保存了本地变量,包括输入输出参数和本地变量;保存类文件和方法等帧数据,还记录了出栈入栈操作。每一个方法被调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
堆在JVM是所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁。
执行引擎(Execution Engine)
执行引擎由三个模块组成,分别是执行引擎,JIT Compiler和Garbage Collector,执行引擎的核心是Jit Compiler,执行字节码或者本地方法;垃圾回收器,则是一系列线程,负责管理分代堆内存。
三个模块分别是运行时计算和运行时内存的管理,负责执行运行时指令的是执行引擎,通过程序寄存器和虚拟机栈中的栈帧出入栈实现方法和指令的执行。GC则负责堆内存的管理,因为GC的时候需要停止指令的执行,消耗资源,所以采用分代方式管理对象收集。JIT则是把字节码编译成本地二进制代码,并调用本地库执行。
GC垃圾回收机制
Java的内存管理,主要是针对的堆内存,因为堆内存是运行时程序和数据分配的空间;不同于内存的其他区域,加载完程序之后,基本上可以确定需要占用的空间大小;heap memory 空间会在运行时动态的分配,无法预测,可大可小,而且快速变化,管理不慎就容易产生内存溢出,所以由JVM提供了强大的分代内存管理机制。
JVM 使用分代内存管理,来分配运行时的堆内存结构,针对不同的世代,采用不同的垃圾回收算法。
常用垃圾回收算法
- 引用计数器法(Reference Counting)
- 标记清除法(Mark-Sweep)
- 复制算法(Coping)
- 标记压缩法(Mark-Compact)
- 分代算法(Generational Collecting)
- 分区算法(Region)
堆内存的组成
heap 的组成有三区域/世代:分别是新生代(Young Generation)、老生代(Old Generation/tenured)和永久区(Perm)。
新生代堆内存又分成Eden区和两个生存区,其中Eden区和生存区的占比为8:1:1,在清理新生代内存的时候,使用的是复制清除算法,优点是清除以后不会产生碎片;简单的复制算法,将内存分成大小相同的两个区域,每次周期只分配其中的一半,这样空间利用率比较低,只使用了一半的内存。
考虑到新生代内存区的对象都是周期很短的,所以JVM实现了一种优化的复制算法,设置一个较大的Eden区来分配对象内存,Eden区空间不够了触发垃圾回收,将上一个生存区和Eden区中还存活的对象,复制到空闲的生存区。然后清空上述两个区域,这样就不会产生内存碎片。
将清理一定次数(15次)还生存的对象,定期晋升到老生代内存区,如果生存区空间不够了,则马上就会触发晋升机制。将部分对象直接晋升到老生代。
如果晋升之后,发现老生代内存不够,就会触发完整的全局GC,清理老生代和新生代内存,老生代内存清理需要使用标记清除和标记整理两种算法。
GC工作原理
分配内存的时候,首先分配到新生代的Eden区,如果Eden区满了,就会发起一次Minor GC,将Eden和From Survivor生存的对象,拷贝到To Survivor Space,如果清理过程中,to Space的空间占用达到一定阈值,或者有对象经历Minor GC的次数达标,就会将对象移动到老生代内存。如果移动过程中发现,老生代内存的空间已经不够了。这时就需要发起Full GC,先进行一次Minor GC,然后通过CMS进行标记清除算法,清理老生代内存,老生代内存经历标记清除之后,因为会产生内存碎片,还需要采用标记整理算法,将所有内存块往前移动,形成连续的内存空间。
老生代标记清除的优点是不需要额外空间。不同于老生代清除算法,会产生碎片,而且标记算法的成本开销也很大;在新生代清除中,因为考虑到大多数新生代对象生存期都是很短暂的,可以使用一种空间换时间的思路,拿出一部分内存空间不分配,而是作为中转,将每次检查时还生存的对象拷贝到Survivor Space,然后直接清除所有原区域的对象,因为大量对象都是生存周期极短的,所以Survivor Space的空间可以远小于正常分配的空间。
不同于引用计数方法,Java使用一种 GC Roots 的对象作为起点开始检查对象,当一个对象到 GC Roots 没有任何引用链相连时, 即该对象不可达, 也就说明此对象是不可用的。就会在GC的时候收回。
GC清理类型的时候,为了防止程序地址出现异常,需要stop the world,清理线程会停止所有运行线程,直到清理完,这个时候是影响性能的。
垃圾回收器的本质
垃圾回收器在JVM层面,是由一系列不同的组件组成的,每种组件是一个独立线程,分别执行自己的逻辑。
新生代垃圾收集器:
- Serial(串行)收集器是最基本、发展历史最悠久的收集器,它是采用复制算法的新生代收集器,。它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。
- ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。
- Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。
老生代垃圾收集器:
- Serial Old 是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法
- Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,优点是:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)
面向服务端的G1收集器。
G1收集器是一款面向服务端应用的垃圾收集器。
在使用G1收集器时,Java堆的内存布局和其他收集器有很大的差别,它将这个Java堆分为多个大小相等的独立区域,虽然还保留新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
GC回收的触发条件
Minor GC触发条件:当Eden区满时,触发Minor GC
Full GC触发条件:
- gc()方法的调用
- 老年代代空间不足
- 方法区空间不足
- CMS GC时出现promotion failed和concurrent mode failure
- 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间
- 堆中分配很大的对象
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
GC Roots
在Java语言中,可以作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即一般说的Native方法)引用的对象;
总结就是,方法运行时,方法中引用的对象;类的静态变量引用的对象;类中常量引用的对象;Native方法中引用的对象。
JVM实战—1.Java代码的运行原理
大纲
1.Java代码到底是如何运行起来的
2.JVM类加载机制的一系列概念
3.JVM中有哪些内存区域及各自的作用
4.JVM的垃圾回收机制的作用
5.问题汇总
1.Java代码到底是如何运行起来的
(1)首先假设写好了一个Java系统
(2)把.java代码文件编译成.class字节码文件
(3)启动JVM进程运行.class字节码文件
(4)类加载器加载字节码文件到JVM
(5)JVM会基于字节码执行引擎来执行加载到内存里的那些类
(6)问题
(1)首先假设写好了一个Java系统
那这个Java系统中会包含很多以\”.java\”为后缀的代码文件,比如User.java。
当写好这个Java系统后,应如何部署到线上机器运行呢?一般会先将Java代码打包成以\”.jar\”为后缀的jar包或者是以\”.war\”为后缀的war包,然后把打包好的jar包或者war包放到线上机器进行部署。
部署的方式有多种,常见的一种方式就是通过Tomcat容器来进行部署,当然也可以通过\”java\”命令来运行一个jar包中的代码。如下图示:
(2)把.java代码文件编译成.class字节码文件
对写好的\”.java\”代码进行打包的过程中,会把代码编译成以\”.class\”为后缀的字节码文件,比如User.class。这些以\”.class\”为后缀的字节码文件才是可以被JVM运行的。如下图示:
(3)启动JVM进程运行.class字节码文件
这时就需要使用诸如\”java -jar\”之类的命令来运行打包好的jar包了。一旦执行\”java\”命令,就会启动一个JVM进程,这个JVM进程会负责运行这些\”.class\”字节码文件。
所以在一台机器上部署一个写好的Java系统时,其实就是启动了一个JVM进程,由JVM来负责这个Java系统的运行。
(4)类加载器加载字节码文件到JVM
接着下一步,JVM要运行这些\”.class\”字节码文件中的代码。首先会把这些\”.class\”文件中包含的各种类加载到JVM里。这些\”.class\”文件就是写好的一个个类,此时就会涉及一个叫\”类加载器\”的概念。也就是采用类加载器把编译好的\”.class\”字节码文件给加载到JVM中,然后供后续代码使用,如下图示:
(5)JVM会基于字节码执行引擎来执行加载到内存里的那些类
比如代码中有个main()方法,那么JVM就会从main()方法开始执行代码。需要哪个类就会使用类加载器进行加载,对应的类就在\”.class\”文件中,如下图示:
(6)问题
既然\”.java\”文件可编译成\”.class\”文件运行,那么\”.class\”文件也可以反编译成\”.java\”文件。但这样的话,由于公司的系统代码编译好之后,都是\”.class\”的格式。如果被别人拿到,进行反编译就可以窃取公司的核心系统的源代码了。所以应该如何处理\”.class\”文件,才能保证\”.class\”文件可以不被反编译来获取公司源代码?
首先在编译时,可以采用一些小工具对字节码加密,或者做混淆处理等。现在有很多公司专门做商业级的字节码文件加密,可付费购买其产品。然后在类加载时,对加密的类采用自定义的类加载器来解密文件即可,这样就可以保证源代码不被人窃取。
2.JVM类加载机制的一系列概念
(1)JVM的整体运行原理
(2)JVM在什么情况下会加载一个类
(3)验证、准备和初始化的过程
(4)核心阶段——初始化
(5)类加载器和双亲委派机制
(1)JVM的整体运行原理
首先把\”.java\”代码文件编译成\”.class\”字节码文件,然后类加载器把\”.class\”字节码文件中的类加载到JVM中,接着JVM会从main()方法开始执行类中的代码。
(2)JVM在什么情况下会加载一个类
一个类从加载到使用,一般会经历如下过程:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
问题:JVM在执行写好的代码过程中,一般什么情况下会去加载一个类?也就是说,什么时候会从\”.class\”字节码文件中加载这个类到JVM内存?答案非常简单,就是在代码中用到这个类的时候。
比如下面有一个类(Kafka.class),里面有一个main()方法作为主入口。一旦JVM进程启动后,它一定会先把这个类(Kafka.cass)加载到内存里,然后从main()方法的入口代码开始执行。
接着假设上面的代码中出现了如下一行代码:
新增的代码中明显需要使用ReplicaManager这个类去实例化一个对象,此时就要把ReplicaManager.class字节码文件中的这个类加载到内存中。
所以这时JVM就会通过类加载器从ReplicaManager.class字节码文件中,加载对应的ReplicaManager类到内存里使用,这样代码才能跑起来,如下图示:
简单总结一下:首先代码中包含main()方法的主类会在JVM进程启动后被加载到内存,然后JVM进程会开始执行main()方法中的代码,接着遇到使用别的类就会从对应的\”.class\”字节码文件加载该类到内存。
(3)验证、准备和初始化的过程
一.验证阶段
这一步会根据虚拟机规范,校验加载的\”.class\”文件内容,是否符合规范。假如\”.class\”文件被篡改,里面的字节码不符合规范,JVM是没法执行的。
所以把\”.class\”加载到内存里后,必须先验证一下。校验它必须完全符合JVM规范,后续才能把它交给JVM来运行。如下图示:
二.准备阶段
我们写好的那些类,其实都有一些类变量,比如这个ReplicaManager类:
当这个类的ReplicaManager.class文件内容被加载到内存后,就会进行验证,来确认这个字节码文件的内容是规范的。
接着就会进行准备工作,这个准备工作就是给这个ReplicaManager类分配一定的内存空间。然后给它的类变量(static修饰的变量)分配内存空间,设置默认的初始值。比如给flushInterval这个类变量分配内存空间,并进行初始化0值。整个过程如下图示:
三.解析阶段
这个阶段就是把符号引用替换为直接引用的过程,如下图示:
四.三个阶段的总结
这三个阶段里,最关键的其实就是准备阶段。准备阶段会给加载进来的类分配好内存空间,以及也会对类变量分配好内存空间,并设置默认初始值。
(4)核心阶段——初始化
一.类的初始化示例
在准备阶段时,会对ReplicaManager类分配好内存空间,另外它的一个类变量flushInterval也会设置一个默认的初始值0。所以接下来在初始化阶段就会正式执行类的初始化代码,类的初始化代码如下所示:
可以看到,flushInterval这个类变量会通过Configuration.getInt(\”flush.interval\”)获取一个值来进行赋值。但是在准备阶段是不会执行这个具体的赋值逻辑的。在准备阶段仅仅给flushInterval类变量开辟内存空间,然后设置初始值0。在初始化阶段,才会执行类的初始化代码。比如执行Configuration.getInt(\”flush.interval\”)代码,读取一个配置项,然后赋值给类变量flushInterval。
另外,如下的static静态代码块,也会在这个初始化阶段执行。如下代码可理解为,在类初始化时调用loadReplicaFromDisk()方法。然后从磁盘中加载数据副本,并且放在静态变量replicas中。
二.类的初始化时机
什么时候会初始化一个类,一般来说有以下时机:
一.使用new关键字实例化类的对象
此时会触发类的加载到初始化的全过程,把类准备好,然后实例化对象。
二.包含main()方法的主类必须马上初始化
三.如果初始化一个类时发现父类还没初始化,则必须先初始化其父类
初始化父类是一个非常重要的规则,如下代码所示:
如果要初始化ReplicaManager类的实例,则会加载该类,然后初始化。但初始化该类之前,发现AbstractDataManager父类还没加载和初始化,那么就必须先加载这个AbstractDataManager父类,并初始化这个父类。如下图示:
(5)类加载器和双亲委派机制
介绍完类加载从触发时机到初始化的过程后,接下来介绍类加载器。因为实现上述过程,必须依靠类加载器来实现。
Java里有如下这些几种类加载器:
一.启动类加载器
这个类加载器主要负责加载Java安装目录(\”lib\”目录)下的类。如果要在一个机器上运行写好的Java系统,都得先装一下JDK。那么在Java安装目录下,就有一个\”lib\”目录。\”lib\”目录里就有Java最核心的一些类库,支撑写好的Java系统的运行。JVM一旦启动,就会使用启动类加载器去加载\”lib\”目录中的核心类库。
二.扩展类加载器
这个类加载器主要负责加载Java安装目录(\”lib\\ext\”目录)下的类。这个类加载器其实也是类似的,就是在Java安装目录下,有一个\”lib\\ext\”目录。该目录有一些类需要使用这个类加载器来加载,以支撑Java系统的运行。那么JVM一旦启动,也得在Java安装目录下加载\”lib\\ext\”目录中的类。
三.应用程序类加载器
这个类加载器就是负责加载\”ClassPath\”环境变量所指定的路径中的类。其实可以理解为去加载我们写好的Java代码,这个类加载器就负责加载我们写好的那些类到内存里。
四.自定义类加载器
除了上面几种外,还可以自定义类加载器,根据需求加载我们的类。
五.双亲委派机制
JVM的类加载器是有亲子层级结构的:启动类加载器在最上层,扩展类加载器在第二层,第三层是应用程序类加载器,最后一层是自定义类加载器。
然后基于这个亲子层级结构,就有一个双亲委派的机制。当我们的应用程序类加载器需要加载一个类的时候,它会委派给自己的父类加载器去加载,最终传导到启动类加载器去加载。但是如果父类加载器在自己负责加载的范围内,没找到这个类,那么父类加载器就会下推加载权利给自己的子类加载器去进行加载。
比如JVM现在需要加载ReplicaManager类,此时应用程序类加载器会找其父类加载器(扩展类加载器)帮忙加载该类。然后扩展类加载器也会找其父类加载器(启动类加载器)帮忙加载该类。启动类加载器在\”lib\”下没找到该类,就会下推加载权利给扩展类加载器。扩展类加载器在\”lib/ext\”下也没找到该类,则让应用程序类加载器加载。然后应用程序类加载器在其负责范围内,比如由系统打包成的jar包中,发现了ReplicaManager类,于是它就会把这个类加载到内存里去。
这就是所谓的双亲委派模型:先找父亲去加载,加载不了再由儿子加载,这样就可以避免多层级的类加载器重复加载某些类。否则不同层级的类加载器加载高层级类加载器的类时就会出现重复加载。
(6)问题
当采用Tomcat之类的Web容器来部署Java开发的系统时:Tomcat本身就是用Java写的,它自己就是一个JVM。而开发好的程序就是一堆编译好的.class文件,会打包成一个war包,然后这些war包就会被放入到Tomcat中运行。
Tomcat的类加载机制应怎么设计才能把动态部署进去的war包中的类加载到Tomcat自身运行的JVM中,然后去执行那些开发好的代码?也就是对于Tomcat这种Web容器中的类加载器应该如何设计?
首先Tomcat的类加载器体系如下图所示,它会自定义很多类加载器。
Tomcat自定义了Common、Catalina、Shared等类加载器,其实这些类加载器就是用来加载Tomcat自己的一些核心基础类库的。
然后部署在Tomcat里的每个Web应用都有对应的WebApp类加载器,这些WebApp类加载器负责加载我们部署的这个Web应用的类。至于JSP类加载器,则是给每个JSP都准备了一个JSP类加载器。
而且需要注意的是:Tomcat是打破了双亲委派机制的,每个WebApp负责加载自己对应的那个Web应用的class文件。即war包中的所有class文件,不会传导给上层类加载器去加载。
3.JVM中有哪些内存区域及各自的作用
(1)JVM类加载的机制问题总结
(2)什么是JVM的内存区域划分
(3)存放类的方法区
(4)执行代码指令用的程序计数器
(5)Java虚拟机栈
(6)Java堆内存
(7)核心内存区域的全流程总结
(8)其他内存区域
(1)JVM类加载的机制问题总结
什么情况下会触发类的加载?
加载后的验证、准备和解析分别干什么?
准备和初始化阶段会如何为类分配内存空间?
然后类加载器的时机和规则是什么?
如下图示:
(2)什么是JVM的内存区域划分
其实这个问题非常简单。JVM在运行我们写好的代码时,会使用多块内存空间,不同的内存空间用来放不同的数据。然后配合我们写的代码逻辑,才能让系统运行起来。
比如现已知JVM会加载类到内存来供后续运行,那么这些类加载到内存以后会放到哪里?所以JVM里必须有一块内存区域,用来存放我们写的那些类。如下图示:
我们的代码运行起来时,需要执行我们写的一个一个的方法。方法里会有很多变量,这些变量要放在某个内存区域里。类和方法里会创建一些对象,这些对象也需要放在某个内存空间里。如下图示:
这就是为什么JVM中必须划分出来不同的内存区域,它是为了我们写好的代码在运行过程中不同的需要来使用的。接下来,依次看JVM中有哪些内存区域。
(3)存放类的方法区
这个方法区是在JDK 1.8以前的版本里,代表JVM中的一块区域。主要存放从\”.class\”文件加载进来的类,还会存放一些类似常量池的东西。但在JDK 1.8后,这块区域的名字改了,叫Metaspace——元数据空间。当然这里主要还是存放我们自己写的各种类相关的信息。
假设有一个Kafka.class类和ReplicaManager.class类,如下所示:
这两个类加载到JVM后,就会放在这个方法区中,如下图示:
(4)执行代码指令用的程序计数器
如下代码所示:
上面这段代码首先会存在于\”.java\”文件里,该文件就是Java源代码文件。但是这个文件是面向开发者的,计算机是看不懂开发者写的这段代码的。所以此时要通过编译器,把\”.java\”源代码文件编译为\”.class\”字节码文件。这个\”.class\”字节码文件里存放的就是根据写出来的代码编译好的字节码。
字节码才是计算器可以理解的一种语言,而不是写出来的那一堆代码。字节码看起来大概如下,跟上面的代码无关,只是一个示例而已。
这段字节码可以大概看到\”.java\”翻译成\”.class\”后大概是什么样。比如\”0: aload_0\”就是\”字节码指令\”,它会对应一条一条的机器指令。计算机只有读到这种机器码指令,才知道具体应该要干什么。比如字节码指令可从内存里读取某个数据,或者把某个数据写入到内存,各种各样的指令可以指示计算机去干各种各样的事情。
现在Java代码通过JVM跑起来的第一件事情就明确了。首先Java代码被编译成\”.class\”文件中的字节码指令,然后字节码指令一定会被一条一条执行,这样才能实现代码的逻辑效果。当JVM加载类信息到内存后,就会使用自己的字节码执行引擎,去执行我们写的代码编译出来的代码指令,如下图示:
在执行字节码指令时,JVM就需要一个特殊的内存区域——程序计数器。程序计数器就是用来记录当前执行的字节码指令位置的,也就是记录目前执行到了哪一条字节码指令。如下图示:
由于JVM是支持多个线程的,所以我们写好的代码可能会开启多个线程并发执行不同的代码,所以就会有多个线程来并发的执行不同的代码指令。因此每个线程都会有一个程序计数器,专门记录执行到哪条字节码指令。如下图示:
(5)Java虚拟机栈
Java代码在执行时,一定是通过线程来执行某个方法中的代码。即便是下面的代码,也会有一个main线程来执行main()方法里的代码。在main线程执行main()方法的代码指令时,就会通过main线程对应的程序计数器记录自己执行的指令位置。
但是在方法里,我们经常会定义一些方法内的局部变量。比如在上面的main()方法里,就有一个replicaManager局部变量,这个局部变量引用了一个ReplicaManager实例对象。因此JVM必须有一块区域来保存每个方法内的局部变量等数据,这个区域就是Java虚拟机栈。
每个线程都有自己的Java虚拟机栈。比如main线程有一个Java虚拟机栈,用来存放执行的方法的局部变量。如果线程执行了一个方法,就会对这个方法调用创建对应的一个栈帧,栈帧里就有这个方法的局部变量表、操作数栈、动态链接、方法出口等。
一.比如main线程执行了main()方法
那么就会为main()方法创建一个栈帧然后压入main线程的Java虚拟机栈,同时在main()方法的栈帧里会存放对应的\”replicaManager\”局部变量。这个main线程执行main()方法过程,如下图示:
二.假设main线程继续执行其栈帧的局部变量里对象的方法
也就是执行ReplicaManager对象的方法,在loadReplicasFromDisk()方法里定义一个局部变量hasFinishedLoad。
那么main线程在执行上面的loadReplicasFromDisk()方法时,就会为loadReplicasFromDisk()方法创建一个栈帧压入Java虚拟机栈里,然后在栈帧的局部变量表里就会有hasFinishedLoad这个局部变量。如下图示:
三.接着loadReplicasFromDisk()方法调用isLocalDataCorrupt()方法
isLocalDataCorrupt()方法里也有自己的局部变量,代码如下:
此时会为isLocalDataCorrupt()方法创建一个栈帧压入Java虚拟机栈,而且isLocalDataCorrupt()方法栈帧的局部变量表有一个isCorrupt变量,isCorrupt变量就是isLocalDataCorrupt()方法的局部变量。整个过程如下图示:
四.接着isLocalDataCorrupt()方法执行完毕
那么会把isLocalDataCorrupt()方法对应的栈帧从Java虚拟机栈里出栈。如果loadReplicasFromDisk()方法执行完毕,也会将对应的栈帧出栈。
上述就是JVM中的\”Java虚拟机栈\”这个组件的作用:调用执行任何方法时,都会给方法创建栈帧然后入栈,在栈帧里存放这个方法对应的局部变量等数据。这些数据包括这个方法执行的相关信息,方法执行完毕后栈帧就出栈。
所以每个线程在执行代码时,除了程序计数器外,还搭配了一个Java虚拟机栈内存区域来存放每个方法中的局部变量表。
每个线程都会有一个程序计数器,专门记录目前执行到哪条字节码指令。
每个线程都有自己的Java虚拟机栈。
每个虚拟机栈都会存放线程执行方法的栈帧。
每个方法的栈帧都会存放该方法的局部变量。
开始执行方法,方法的栈帧入线程虚拟机栈。
执行完方法,方法的栈帧出线程的虚拟机栈。
(6)Java堆内存
现在已知,main线程执行main()方法时,会有自己的程序计数器。此外,还会依次把main()方法、loadReplicasFromDisk()方法、isLocalDataCorrupt()方法的栈帧压入Java虚拟机栈,存放其局部变量。
那么接着就得来看JVM中的另外一个非常关键的区域,就是Java堆内存。Java堆内存就是用来存放我们在代码中创建的各种对象的,比如下面的代码:
上面new ReplicaManager()创建了一个ReplicaManager类的对象实例。这个ReplicaManager对象实例里面会包含一些数据,如下代码所示。这个ReplicaManager类的replicaCount属于这个对象实例的一个数据,类似ReplicaManager这样的对象实例,就会存放在Java堆内存里。
Java堆内存区域里会放入类似ReplicaManager的对象,由于在main方法里创建了ReplicaManager对象,所以会在main方法对应的栈帧的局部变量表里,让引用类型的replicaManager局部变量存放ReplicaManager对象的地址。可认为局部变量表里的replicaManager指向了堆的ReplicaManager对象。如下图示:
(7)核心内存区域的全流程总结
整体流程图如下:
整体代码如下:
首先,JVM进程启动时就会先加载Kafka类到内存里。然后有一个main线程,开始执行Kafka中的main()方法。main线程有一个程序计数器,执行到哪行指令,程序计算器就会记录。
其次,main线程在执行main()方法时,会在main线程关联的Java虚拟机栈里,压入一个main()方法的栈帧。接着发现要创建一个ReplicaManager类的实例对象,此时会首先加载ReplicaManager类到内存,然后会创建一个ReplicaManager的对象实例分配在Java堆内存里,并且在main()方法的栈帧里的局部变量表引入一个replicaManager变量,让replicaManager变量引用ReplicaManager对象在Java堆内存中的地址。
接着,main线程开始执行ReplicaManager对象中的方法,于是会依次把自己执行到的方法的栈帧压入自己的Java虚拟机栈,各个方法执行完之后再把方法对应的栈帧从Java虚拟机栈里出栈。
以上就是JVM各个核心内存区域的功能和对应Java代码之间的关系总结。
(8)其他内存区域
其实在JDK很多底层API里,其内部的源码很多都不是Java代码,比如IO、NIO、Socket等是通过native方法来调用操作系统里的方法。这些方法可能是C语言写的方法,或者一些底层类库的方法,比如下面这样的:
在调用这种native方法时,就会有线程对应的本地方法栈。本地方法栈类似Java虚拟机栈,存放native方法的局部变量表等信息。
还有一个区域,是不属于JVM的。通过NIO中的allocateDirect这种API,可以在Java堆外分配内存空间,然后通过Java虚拟机里的DirectByteBuffer来引用和操作堆外内存空间。很多技术都会用这种方式,因为某些场景下堆外内存分配可提升性能。
(9)问题
在Java堆内存中分配的那些对象,到底会占用多少内存?一般怎么来计算和估算系统创建的对象会占用多少内存?
我们创建的那些对象,到底在Java堆内存里会占用多少内存空间?这个其实很简单,一个对象对内存空间的占用,大致分为两块:
一.对象自己本身的一些信息
二.对象的实例变量作为数据占用的空间
比如对象头,如果在64位的Linux操作系统上,会占用16字节。然后如果我们的实例对象内部有个int类型的实例变量,会占用4个字节。如果是long类型的实例变量,会占用8个字节。如果是数组、Map之类的,那么就会占用更多的内存。
对象占用的内存:Object Header(16字节) + Class Pointer(4字节) + Fields(对象存放类型)。但JVM对象的内存占用大小是8的倍数,所以结果要向上取整到8的倍数。另外JVM对这块有很多优化的地方,比如补齐机制、指针压缩机制。
4.JVM的垃圾回收机制的作用
(1)JVM中几块内存区域的作用总结
(2)对象的分配与引用
(3)一个方法执行完毕后会怎样
(4)创建的Java对象都是要占用内存资源的
(5)不再需要的那些对象应该怎么处理
(1)JVM中几块内存区域的作用总结
代码在运行时,有一个main线程会去执行所有代码。然后线程执行时会通过自己的程序计数器记录执行到哪一个代码指令。另外线程在执行方法时会为每个方法创建一个栈帧放入自己的虚拟机栈,每个方法的栈帧里都有该方法的局部变量。最后就是代码运行过程中创建的各种对象,都会放在Java堆内存里。
JVM中的内存区域:元数据区 + 程序计数器+ 虚拟机栈+ 本地方法栈 + Java堆 + 堆外内存。
(2)对象的分配与引用
假设有下面一段代码,该代码会通过loadReplicasFromDisk()方法去磁盘加载需要的副本数据,然后通过ReplicaManager对象实例完成这个加载操作。
结合JVM运行原理,下面分析上述代码的运行流程。
一.首先一个main线程会执行main()方法里的代码
main线程自己是有一个Java虚拟机栈的,JVM它会把main()方法的栈帧压入main线程的Java虚拟机栈里,如下图示:
二.然后main()方法里由于调用了loadReplicasFromDisk()方法
此时会创建loadReplicasFromDisk()方法的栈帧,然后压入main线程的虚拟机栈里,如下图示:
三.接着在loadReplicasFromDisk()方法里发现一个repliaManager变量
于是往loadReplicasFromDisk()方法的栈帧放入一个repliaManager变量,如下图示:
四.然后发现在代码里创建了一个ReplicaManager类的实例对象
此时就会在Java堆内存中分配这个实例对象的内存空间,同时让loadReplicasFromDisk()方法的栈帧内的replicaManager局部变量,去指向那个Java堆内存里的ReplicaManager实例对象。如下图示:
接着就通过replicaManager局部变量引用到ReplicaManager实例对象,执行ReplicaManager实例对象的load()方法,去完成业务逻辑。
(3)一个方法执行完毕之后会怎样
接着上述代码:
一旦方法里的代码执行完毕,那么这个方法就执行完毕了。也就是说此时loadReplicasFromDisk()方法就执行完毕了。而一旦loadReplicasFromDisk()方法执行完毕,就会把loadReplicasFromDisk()方法的栈帧从main线程的虚拟机栈里出栈。如下图示:
此时一旦loadReplicasFromDisk()方法的栈帧出栈,那么那个栈帧里的局部变量replicaManager也就没有了,即没有任何一个变量指向Java堆内存里的ReplicaManager实例对象了。
(4)创建的Java对象都是要占用内存资源的
此时Java堆内存里的那个ReplicaManager实例对象已经没有被引用了,这个对象实际上已经没用了,现在还留在内存里,但内存资源是有限的。
一般会在一台机器上启动一个Java系统,机器的内存资源是4G。然后启动的Java系统本质是一个JVM进程,它负责运行Java系统代码。那么这个JVM进程也会占用机器的部分内存资源,如占用2G内存资源。当Java系统在JVM的堆内存中创建对象时,就会占用JVM的内存资源,比如ReplicaManager实例对象占用500字节的内存。
所以在Java堆里创建的对象都是会占用内存资源的,而且内存资源有限,如下图示:
(5)不再需要的那些对象应该怎么处理
既然ReplicaManager对象实例不需要使用了,已经没有任何方法的局部变量在引用该实例对象了,而它还占着内存资源,那么就可以使用JVM的垃圾回收机制来进行处理。
JVM会有垃圾回收机制,它是一个后台自动运行的线程。我们只要启动一个JVM进程,就会自带一个垃圾回收的后台线程。这个线程会在后台不断检查JVM堆内存中的各个实例对象,如下图示:
如果某个实例对象没有任何一个方法的局部变量指向它,也没有任何一个类的静态变量包括常量等指向它,那么这个垃圾回收线程就会把这个没人指向的实例对象给回收掉,也就是从内存里清除掉,让它不再占用任何内存资源。
这样这些不再被指向的对象实例,即JVM中的垃圾,就会定期被后台垃圾回收线程清理掉,不断释放内存资源。如下图示:
至此,便介绍完了什么是JVM中的垃圾,以及什么是JVM的垃圾回收。
(6)问题
既然Java堆内存里的对象会被回收,那加载到方法区的类会被回收吗?方法区的类什么时候会被回收?
在以下几种情况下,方法区里的类会被回收:
一.该类的所有实例对象都已从堆内存里回收
二.加载这个类的类加载器ClassLoader已被回收
三.对该类的Class对象没有任何引用
满足上面三个条件就可以回收该类了。
5.问题汇总
问题一:
方法执行完,引用消失,堆内存还未必消失。好多人在做报表导出的时候,就会在for循环里不断的创建对象。这样很容易造成堆溢出,像这种大文件导出怎么处理?
答:不要在for里创建对象,可以在外面创建一个对象,for循环里对一个对象修改数据即可。
问题二:
(1)Java支持多线程,每个线程有自己的Java虚拟机栈和本地方法栈?
(2)新建的实例在堆内存,实例变量也是在堆内存?是这样吗?
答:每个线程有自己的Java虚拟机栈和本地方法栈。对象实例存放在Java堆内存中,实例变量存放在Java虚拟机栈中。
问题三:
如果有一个静态的成员变量int,那么多线程更改是否有线程安全问题?
答:静态成员变量,在内存里只有一份,属于类的,不属于线程私有。多个线程并发修改,一定会有并发问题,可能导致数据出错。
问题四:
类加载是按需加载,可以一次性加载全部的类吗?
答:如果是默认的类加载机制,就是运行过程中遇到什么类加载什么类。如果你要自己加载类,就需要写自己的类加载器;
问题五:
为什么要一级一级的往上找,能否直接从顶层类加载器开始往下找?
答:双亲委派模型的工作过程是:
如果一个类加载器收到了类加载请求,它首先不会自己尝试加载这个类。而是把该请求委派给父类加载器去完成,每一个层次的类加载器都如此。因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有当父加载器己无法完成加载请求时,子加载器才会尝试去完成加载。
这样做的一个显而易见的好处就是:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,可保证例如Object类在程序的各种类加载器环境中都是同一个类。否则用户编写一个名为java.lang.Object类,并放在程序的ClassPath中,那么系统就会出现多个不同的Object类,当然还双亲委派机制可以避免重复加载同一个类。
从java.lang.ClassLoader的loadClass()方法就可以知道:如果从顶层开始找,就要将parent换成child + 对child硬编码,才能找到。也就是说需要改动loadClass()方法,需要把parent换成child + 硬编码,硬编码也要先从下往上逐层获得整个父子路径才能直接从顶层往下找到。
loadClass()方法的逻辑如下:
先检查请求加载的类是否已被加载过。若没有则调用父加载器的loadClass(),若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载器加载失败则抛异常,才调用自己的findClass()尝试进行加载。
问题六:
包含main方法的类优先加载,一个项目中多个类都有main方法都加载么?
答:不会的,因为启动一个jar包需要指定某个main主类,优先会加载它。
问题七:
类加载双亲委派机制中,为什么要先找父加载而不是自己找?
答:每个层级的类加载器各司其职,而且不会重复加载一个类。比如代码用两个不同层级的类加载器,尝试加载父类加载器的某个类。如果有双亲委派机制,那么都会先找父类加载器去加载。父类加载器加载到了之后,以后都会由父类加载器去加载这个类。否则如果没有双亲委派机制,则会出现如下情况:两个不同层级的类加载器就要各自加载,从而出现重复加载同一个类。比如要加载一个\”lib\”目录下的类,根据双亲委派机制loadClass()代码:应用程序类加载器初次加载后,它的父类加载器也完成对该类的加载,以后其他应用程序类加载器再次加载该类,就可通过父类加载器加载。
问题八:
自定义类加载器如何实现?
答:自定义一个类继承ClassLoader类,重写类加载的findClass()方法,然后在代码里可用自定义类加载器去针对某个路径下的类加载到内存里。
问题九:
动态部署和静态部署的区别是什么?
答:假设在Tomcat部署系统。那么动态部署(热部署),就是直接把系统放入Tomcat对应目录,Tomcat会自动重新加载最新的代码(热部署),无需对Tomcat停机再重启。静态部署就是先停止Tomcat,然后把最新代码放到目录,再重启Tomcat。
问题十:
类加载器是否会把jar包里的所有类一次性加载进去?
答:不会。首先会加载main方法的主类,然后在运行时遇到什么类再加载什么类。
本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com
文章为作者独立观点不代本网立场,未经允许不得转载。