面试总结--JVM部分(5)
JVM相关
1、JVM运行时内存划分?PC+虚拟机栈+本地方法栈+堆+方法区+JDK1.7与1.8区别
JVM 内存区域
JVM会将Java进程所管理的内存划分为若干不同的数据区域. 这些区域有各自的用途、创建/销毁时间:
线程私有区域
线程私有数据区域生命周期与线程相同,依赖用户线程的启动/结束而创建/销毁(在Hotspot VM内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死).
- Program Counter Register(程序计数器):
一块较小的内存空间,作用是当前线程所执行字节码的行号指示器(类似于传统CPU模型中的PC), PC在每次指令执行后自增, 维护下一个将要执行指令的地址. 在JVM模型中, 字节码解释器就是通过改变PC值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖PC完成(仅限于Java方法, Native方法该计数器值为undefined).
不同于OS以进程为单位调度, JVM中的并发是通过线程切换并分配时间片执行来实现的. 在任何一个时刻, 一个处理器内核只会执行一条线程中的指令. 因此, 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 这类内存被称为“线程私有”内存. - Java Stack(虚拟机栈)
虚拟机栈描述的是Java方法执行的内存模型: 每个方法被执行时会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息. 每个方法被调用至返回的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程(VM提供了-Xss来指定线程的最大栈空间, 该参数也直接决定了函数调用的最大深度).
局部变量表(对应我们常说的‘堆栈’中的‘栈’)存放了编译期可知的各种基本数据类型(如boolean、int、double等) 、对象引用(reference : 不等同于对象本身, 可能是一个指向对象起始地址的指针, 也可能指向一个代表对象的句柄或其他与此对象相关的位置, 见下: HotSpot对象定位方式) 和 returnAddress类型(指向一条字节码指令的地址). 其中long和double占用2个局部变量空间(Slot), 其余只占用1个. 如下Java方法代码可以使用javap命令或javassist等字节码工具读到:
public String test(int a, long b, float c, double d, Date date, List list) {
StringBuilder sb = new StringBuilder().append(a).append(b).append(c).append(d).append(date);
for (String str : list) {
sb.append(str);
}
return sb.toString();
}
注: javap/javassist读到的其实是静态数据, 而局部变量表内存储的却是运行时动态加载的动态数据, 但因为局部变量表所需的内存空间在编译期间即可完成分配, 当进入一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间大小不会改变, 因此可以在概念上认定这两部分内容存储的数据格式相同.
3.Native Method Stack(本地方法栈)
与Java Stack作用类似, 区别是Java Stack为执行Java方法服务, 而本地方法栈则为Native方法服务, 如果一个VM实现使用C-linkage模型来支持Native调用, 那么该栈将会是一个C栈, 但HotSpot VM直接就把本地方法栈和虚拟机栈合二为一.
线程共享区域
随虚拟机的启动/关闭而创建/销毁.
- Heap(Java堆)
几乎所有对象实例和数组都要在堆上分配(栈上分配、标量替换除外), 因此是JVM管理的最大一块内存, 也是垃圾收集器的主要活动区域. 由于现代JVM采用分代收集算法, 因此Java堆从GC的角度还可以细分为: 新生代(Eden区、From Survivor区和To Survivor区)和老年代; 而从内存分配的角度来看, 线程共享的Java堆还还可以划分出多个线程私有的分配缓冲区(TLAB). 而进一步划分的目的是为了更好地回收内存和更快地分配内存. - Method Area(方法区)
即我们常说的永久代(Permanent Generation),用于存储被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)
不过在1.7的HotSpot已经将原本放在永久代的字符串常量池移出:
而在1.8中, 永久区已经被彻底移除, 取而代之的是元数据区Metaspace(这一点在查看GC日志和使用jstat -gcutil查看GC情况时可以观察到),与永久代不同, 如果不指定Metaspace大小, 如果方法区持续增长, JVM会默认耗尽所有系统内存.
运行时常量池
方法区的一部分. Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用, 这部分内容会存放到方法区的运行时常量池中(如前面从test方法中读到的signature信息). 但Java语言并不要求常量一定只能在编译期产生, 即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 如String的intern()方法.
直接内存
直接内存并不是JVM运行时数据区的一部分, 但也会被频繁的使用: 在JDK 1.4引入的NIO提供了基于Channel与Buffer的IO方式, 它可以使用Native函数库直接分配堆外内存, 然后使用DirectByteBuffer对象作为这块内存的引用进行操作, 这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能.
显然,本机直接内存的分配不会受到Java堆大小的限制(即不会遵守-Xms、-Xmx等设置), 但既然是内存, 则肯定还是会受到本机总内存大小及处理器寻址空间的限制, 因此动态扩展时也会出现OutOfMemoryError异常.
HotSpot对象
对象新建
new一个Java Object(包括数组和Class对象), 在JVM会发生如下步骤:
JVM遇到new指令: 首先去检查该指令的参数是否能在常量池中定位到一个类的符号引用, 并检查这个符号引用代表的类是否已被加载、解析和初始化过. 如果没有, 必须先执行相应的类加载过程.
类加载检查通过后:JVM将为新生对象分配内存(对象所需内存的大小在类加载完成后便可完全确定), JVM采用指针碰撞(内存规整: Serial、ParNew等有内存压缩整理功能的收集器)或空闲链表(内存不规整: CMS这种基于Mark-Sweep算法的收集器)方式将一块确定大小的内存从Java堆中划分出来.
除了考虑如何划分可用空间外,由于在JVM上创建对象的行为非常频繁, 因此需要考虑内存分配的并发问题. 解决方案有两个:
对分配内存空间的动作进行同步-采用 CAS配上失败重试 方式保证更新操作的原子性;
把内存分配的动作按照线程划分在不同的空间之中进行-每个线程在Java堆中预先分配一小块内存, 称为本地线程分配缓冲TLAB, 各线程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB时才需要同步锁定(使用-XX:+/-UseTLAB参数设定).
接下来将分配到的内存空间初始化为零值(不包括对象头, 且如果使用TLAB这一个工作也可以提前至TLAB分配时进行). 这一步保证了对象的实例字段可以不赋初始值就直接使用(访问到这些字段的数据类型所对应的零值).
然后要对对象进行必要的设置:如该对象所属的类实例、如何能访问到类的元数据信息、对象的哈希码、对象的GC分代年龄等, 这部分息放在对象头中(详见下).
上面工作都完成之后,在虚拟机角度一个新对象已经产生, 但在Java视角对象的创建才刚刚开始(方法尚未执行, 所有字段还都为零). 所以new指令之后一般会(由字节码中是否跟随有invokespecial指令所决定-Interface一般不会有, 而Class一般会有)接着执行方法, 把对象按照程序员的意愿进行初始化, 这样一个真正可用的对象才算完全产生出来.
对象存储布局
HotSpot VM内, 对象在内存中的存储布局可以分为三块区域:对象头、实例数据和对齐填充:
对象头包括两部分:
一部分是类型指针,即是对象指向它的类元数据的指针: JVM通过该指针确定该对象属于哪个类实例. 另外, 如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.
注意:并非所有JVM实现都必须在对象数据上保留类型指针, 也就是说查找对象的元数据并非一定要经过对象本身(详见下面句柄定位对象方式).
一部分用于存储对象自身的运行时数据: HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等, 这部分数据的长度在32位和64位的JVM(暂不考虑开启压缩指针)中分别为32bit和64bit, 官方称之为“Mark Word”; 其存储格式如下:
实例数据部分是对象真正存储的有效信息,也就是我们在代码里所定义的各种类型的字段内容(无论是从父类继承下来的, 还是在子类中定义的都需要记录下来). 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响. HotSpot默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers), 相同宽度的字段总是被分配到一起, 在满足这个前提条件下, 在父类中定义的变量会出现在子类之前. 如果CompactFields参数值为true(默认), 那子类中较窄的变量也可能会插入到父类变量的空隙中.
对齐填充部分并不是必然存在的,仅起到占位符的作用, 原因是HotSpot自动内存管理系统要求对象起始地址必须是8字节的整数倍, 即对象的大小必须是8字节的整数倍.
对象定位
建立对象是为了使用对象, Java程序需要通过栈上的reference来操作堆上的具体对象. 主流的有句柄和直接指针两种方式去定位和访问堆上的对象:
句柄: Java堆中将会划分出一块内存来作为句柄池, reference中存储对象的句柄地址, 而句柄中包含了对象实例数据与类型数据的具体各自的地址信息:
直接指针(HotSpot使用): 该方式Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址:
这两种对象访问方式各有优势:使用句柄来访问的最大好处是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不变. 而使用直接指针最大的好处就是速度更快, 它节省了一次指针定位的时间开销,由于对象访问非常频繁, 因此这类开销积小成多也是一项非常可观的执行成本.
四、Java堆内存的10个要点
Java堆内存是操作系统分配给JVM的内存的一部分。
当我们创建对象时,它们存储在Java堆内存中。
为了便于垃圾回收,Java堆空间分成三个区域,分别叫作New Generation, Old Generation或叫作Tenured Generation,还有Perm Space。
你可以通过用JVM的命令行选项 -Xms, -Xmx, -Xmn来调整Java堆空间的大小。不要忘了在大小后面加上”M”或者”G”来表示单位。举个例子,你可以用 -Xmx256m来设置堆内存最大的大小为256MB。
你可以用JConsole或者 Runtime.maxMemory(), Runtime.totalMemory(), Runtime.freeMemory()来查看Java中堆内存的大小。
你可以使用命令“jmap”来获得heap dump,用“jhat”来分析heap dump。
Java堆空间不同于栈空间,栈空间是用来储存调用栈和局部变量的。
Java垃圾回收器是用来将死掉的对象(不再使用的对象)所占用的内存回收回来,再释放到Java堆空间中。
当你遇到java.lang.outOfMemoryError时,不要紧张,有时候仅仅增加堆空间就可以了,但如果经常出现的话,就要看看Java程序中是不是存在内存泄露了。
请使用Profiler和Heap dump分析工具来查看Java堆空间,可以查看给每个对象分配了多少内存。
2、堆内存分配策略
堆内存空间结构
Java 堆主要分为2个区域-年轻代与老年代,年轻代包括Eden 区和 Survivor 区,Survivor 区又分From区和 To区。如下图:
Eden区
对象会优先在新生代 Eden 区中进行分配,当 Eden 区空间不足时,虚拟机会使用复制算法发起一次 Minor GC(Young GC),清除掉垃圾对象。之后,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。
Survivor区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲区。如果没有Survivor 区域,Old区将很快被填满,就会触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。
Survivor 又分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(To 区内存不足时,直接进入 Old 区)。
为什么要将Survivor区分成From和To两个区?
为了解决内存碎片化的问题。Minor GC 执行后,Eden 区会清空,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。这时候JVM要使用标记清除算法去清除垃圾对象,而标记清除算法最大的问题就是内存碎片,由于在Eden区中有很多对象是“朝生夕死”的,所以必然会让内存产生严重的碎片化。Survivor 有2个区域,每次 Minor GC时,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,再将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。这样一来,总有一个Survivor区域是空闲的。这样就解决了内存碎片的问题。
Old区
Old区据着2/3的堆内存空间,当对象从新生代中存活下来,就会被拷贝到这里。Major GC 会清理Old区中的对象,每次Major GC 都会触发“Stop-The-World”。内存越大,执行的时间也就越长。由于老年代中对象存活率很高,采用复制算法效率很低,所以老年代垃圾收集采用的是标记整理算法。
内存分配策略
内存分配策略主要有以下几点:
对象优先分配在Eden区,如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。
大对象直接进入老年代(需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。
动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。
3、Minor GC 和Full GC触发条件
GC,即就是Java垃圾回收机制。目前主流的JVM(HotSpot)采用的是分代收集算法。与C++不同的是,Java采用的是类似于树形结构的可达性分析法来判断对象是否还存在引用。即:从gcroot开始,把所有可以搜索得到的对象标记为存活对象。
GC机制
要准确理解Java的垃圾回收机制,就要从:“什么时候”,“对什么东西”,“做了什么”三个方面来具体分析。
第一:“什么时候”即就是GC触发的条件。GC触发的条件有两种。(1)程序调用System.gc时可以触发;(2)系统自身来决定GC触发的时机。
系统判断GC触发的依据:根据Eden区和From Space区的内存大小来决定。当内存大小不足时,则会启动GC线程并停止应用线程。
第二:“对什么东西”笼统的认为是Java对象并没有错。但是准确来讲,GC操作的对象分为:通过可达性分析法无法搜索到的对象和可以搜索到的对象。对于搜索不到的方法进行标记。
第三:“做了什么”最浅显的理解为释放对象。但是从GC的底层机制可以看出,对于可以搜索到的对象进行复制操作,对于搜索不到的对象,调用finalize()方法进行释放。
具体过程:当GC线程启动时,会通过可达性分析法把Eden区和From Space区的存活对象复制到To Space区,然后把Eden Space和From Space区的对象释放掉。当GC轮训扫描To Space区一定次数后,把依然存活的对象复制到老年代,然后释放To Space区的对象。
对于用可达性分析法搜索不到的对象,GC并不一定会回收该对象。要完全回收一个对象,至少需要经过两次标记的过程。
第一次标记:对于一个没有其他引用的对象,筛选该对象是否有必要执行finalize()方法,如果没有执行必要,则意味可直接回收。(筛选依据:是否复写或执行过finalize()方法;因为finalize方法只能被执行一次)。
第二次标记:如果被筛选判定位有必要执行,则会放入FQueue队列,并自动创建一个低优先级的finalize线程来执行释放操作。如果在一个对象释放前被其他对象引用,则该对象会被移除FQueue队列。
GC过程中用到的回收算法:
通过上面的GC过程不难看出,Java堆中的年轻代和老年代采用了不同的回收算法。年轻代采用了复制法;而老年代采用了标记-整理法
具体各种回收算法的详解参考:http://www.cnblogs.com/dolphin0520/p/3783345.html
JVM内存空间图解
程序计数器:线程私有。是一块较小的内存,是当前线程所执行的字节码的行号指示器。是Java虚拟机规范中唯一没有规定OOM(OutOfMemoryError)的区域。
Java栈:线程私有。生命周期和线程相同。是Java方法执行的内存模型。执行每个方法都会创建一个栈帧,用于存储局部变量和操作数(对象引用)。局部变量所需要的内存空间大小在编译期间完成分配。所以栈帧的大小不会改变。存在两种异常情况:若线程请求深度大于栈的深度,抛StackOverflowError。若栈在动态扩展时无法请求足够内存,抛OOM。
Java堆:所有线程共享。虚拟机启动时创建。存放对象实力和数组。所占内存最大。分为新生代(Young区),老年代(Old区)。新生代分Eden区,Servior区。Servior区又分为From space区和To Space区。Eden区和Servior区的内存比为8:1。 当扩展内存大于可用内存,抛OOM。
方法区:所有线程共享。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。又称为非堆(Non – Heap)。方法区又称“永久代”。GC很少在这个区域进行,但不代表不会回收。这个区域回收目标主要是针对常量池的回收和对类型的卸载。当内存申请大于实际可用内存,抛OOM。
本地方法栈:线程私有。与Java栈类似,但是不是为Java方法(字节码)服务,而是为本地非Java方法服务。也会抛StackOverflowError和OOM。
Minor GC ,Full GC 触发条件
Minor GC触发条件:当Eden区满时,触发Minor GC。
Full GC触发条件:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法去空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
1、获取内存详情:jmap -dump:format=b,file=e.bin pid
这种方式可以用 jvisualvm.exe 进行内存分析,或者采用 Eclipse Memory Analysis Tools (MAT)这个工具
2. 获取内存dump: jmap -histo:live pid
这种方式会先出发fullgc,所有如果不希望触发fullgc 可以使用jmap -histo pid
3.第三种方式:jdk启动加参数:
-XX:+HeapDumpBeforeFullGC
-XX:HeapDumpPath=/httx/logs/dump
这种方式会产生dump日志,再通过jvisualvm.exe 或者Eclipse Memory Analysis Tools 工具进行分析
4、如何判断对象是否存活?回收对象的两次标记过程。
在Java堆中存放着几乎所有的对象实例,当垃圾回收器在对Java堆进行回收前,就需要对里面的对象进行是否存活的判断。然而,要真正宣告一个对象的死亡,是需要经历两次标记过程的。接下来就来具体分析。
1、两种判断对象是否存活的方法(但注意,这两种均不能真正宣告对象死亡。)
1.1 引用计数器法
① 工作原理:给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
②优点:实现简单,判定效率也很高;
缺点:他很难解决对象之间相互循环引用的问题。
例子:对象objA与objB都有字段instance,赋值令objA.instance=objB,及obj.instance=objA,除此之外,这两个对象再无任何引用。实际上这两个对象已经不可能再被访问了,但是由于它们相互引用对方——>导致它们的引用计数都不为零——>引用计数算法无法通知GC收集器回收它们。
③注意:上例经过测试后发现,GC日志中包含“4603K->210K”,意味着虚拟机仍然对它们进行了回收——>虚拟机并不是通过引用计数算法来判断对象是否存活的。
1.2 可达性分析算法
①基本思路:通过一系列的成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的。
②Java语言中GC Roots的对象包括下面几种:
(1.虚拟机栈(栈帧中的本地变量表)中引用的对象
(2.方法区中类静态属性引用的对象
(3.方法区中常量引用的对象
(4.本地方法栈JNI(Native方法)引用的对象
2.引用
分析上面所说的两种判定方法后,可以发现它们都与“引用”有关。
JDK1.2以前,引用的定义(纯粹,但太过狭隘)——如果reference(引用)类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。
So——>我们所期望描述这样一类对象:当内存空间足够的时候,能够保留在内存中;但如果内存空间在进行垃圾收集之后还是很紧张,则可以抛弃这些对象。(例如很多系统中的缓存功能)
So——> JDK1.2之后,java对引用的概念进行了扩充,分为以下几种——(由强到弱)
2.1 强引用
就是在程序代码之中普遍存在的,类似Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
2.2 软引用
用来描述一些还有用但并非必须的元素。对于它在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存才会抛出内存溢出异常。(SoftReference类用来实现弱引用)
2.3 弱引用
用来描述非必须对象的,但是它的强度比软引用更弱一些,被引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够都会回收掉只被弱引用关联的对象。(WeakReference类用来实现弱引用)
2.4 虚引用
也称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象是否存在虚引用,完全不会对其生存时间造成影响,也无法通过虚引用来去的一个对象实例。 它的唯一目的就是:能在这个对象被收集器回收时收到一个系统通知。(PhantomReference类用来实现弱引用)
3、真正宣告一个对象生存还是死亡?
即使是在可达性分析算法中不可达的对象,也不并非“非死不可”,此时它们暂时处于“缓刑”阶段。
要真正宣告一个对象死亡,至少要经历两次标记过程,如下图所示:
注意:
1、重新与引用链上的任何一个对象建立关联,譬如可以把自己(this关键字)赋值给某个类变量或者对象的成员变量。
例如:FinalizeEscapeGC.SAVE_HOOK=this;
2、虚拟机触发finalize()方法的语句:System.gc();
Finalize方法:任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此,如果再次使用该方法,对象的自救行动是不会成功的了。
注意:不建议使用这种方法来拯救对象,因为它的运行代价高昂,不确定性大,同时也无法保证各个对象的调用顺序。可以使用try-finally或其他方式来代替,会获得更好的效果。
5、垃圾回收算法以及垃圾回收器介绍,尤其是G1和CMS的优缺点
见第三题的链接
6、创建一个对象的步骤
画图说明一个对象的创建过程做了哪些事情?
Student s = new Student();
- Student.class加载进内存
- 声明一个Student类型引用s
- 在堆内存创建对象,
- 给对象中属性默认初始化值
- 属性进行显示初始化
- 构造方法进栈,对对象中的属性赋值,构造方法弹栈
- 将对象的地址值赋值给s
7、详细介绍类加载过程
我们都知道一个java程序运行要经过编译和执行,但是这太概括了,中间还有很多步骤,今天来说说类加载
学完类加载之后,java运行过程就可以分为 编译>类加载> 执行
类加载主要是由jvm虚拟机负责的,过程非常复杂,类加载分三步加载>连接>初始化,(这里的加载和本文标题的类加载是不同的,标题的类加载包含了完整的三个步骤)下面详细说说每一步的过程
1、加载:这个很简单,程序运行之前jvm会把编译完成的.class二进制文件加载到内存,供程序使用,用到的就是类加载器classLoader ,这里也可以看出java程序的运行并不是直接依 靠底层的操作系统,而是基于jvm虚拟机。如果没有类加载器,java文件就只是磁盘中的一个普通文件。
2、连接:连接是很重要的一步,过程比较复杂,分为三步 验证>准备>解析
验证:确保类加载的正确性。一般情况由javac编译的class文件是不会有问题的,但是可能有人的class文件是自己通过其他方式编译出来的,这就很有可能不符合jvm的编 译规则,这一步就是要过滤掉这部分不合法文件
准备:为类的静态变量分配内存,将其初始化为默认值 。我们都知道静态变量是可以不用我们手动赋值的,它自然会有一个初始值 比如int 类型的初始值就是0 ;boolean类型初始值为false,引用类型的初始值为null 。 这里注意,只是为静态变量分配内存,此时是没有对象实例的
解析:把类中的符号引用转化为直接引用。解释一下符号引用和直接引用。比如在方法A中使用方法B,A(){B();},这里的B()就是符号引用,初学java时我们都是知道这是java的引用,以为B指向B方法的内存地址,但是这是不完整的,这里的B只是一个符号引用,它对于方法的调用没有太多的实际意义,可以这么认为,他就是给程序员看的一个标志,让程序员知道,这个方法可以这么调用,但是B方法实际调用时是通过一个指针指向B方法的内存地址,这个指针才是真正负责方法调用,他就是直接引用。
3、初始化:为类的静态变量赋予正确的初始值,上述的准备阶段为静态变量赋予的是虚拟机默认的初始值,此处赋予的才是程序编写者为变量分配的真正的初始值
现在java程序的执行就可以分为
类加载的内存分析
类的加载过程我们已经了解,现在来分析一下类加载的内存分配,
类加载到底是什么呢?其实类加载不过就是居民虚拟机为类分配了几块内存空间,说的具体一点,就是jvm虚拟机将类的.class文件加载到内存,并将它放到运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构
这里可能很多人不知道什么是运行时方法区。我们简单了解一下jvm虚拟机的内存管理
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。java虚拟机所管理的内存将会包括以下几个运行时数据区域
很多区域的作用大家可能都不知道,这里也不多做解释,我们只需要知道其中的两个
方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
堆区:存放对象实例,几乎所有的对象实例都在这里分配内存
也就是说,类被加载后,方法区会被分出一块内存,存储这个类的所有信息,但是这个内存块存储的依然是.class文件,并不能被我们使用,我们还需要一个能被直接使用的对象,此时堆区就开始发挥作用。类的信息被存储在方法区后,jvm虚拟机又会堆区创建一个java.lang.Class对象,这个对象就好像方法区对应类的一个镜子,把方法区存储的类的结构全部反射过来,然后封装起来,成为了一个Class类的对象(此处运用到反射知识)。这个Class对象与对应的类是一对一服务,因为他有类的结构信息,所以他自然可以构造出一个类的对象。我们平时使用的对象就是由这个Class类的对象生成。到此,类的加载已经完成,但是此时依旧没有我们需要使用的对象产生(这里比较绕,说的比较啰嗦)
反正都啰嗦了,在加一个反应内存的图也不多余
来个总结
• 类的加载的最终产品是位于堆区中的Class 对象
• Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口
说完了类的加载过程,我们还要绕回去,谈谈类为什么会被加载,如何触发jvm虚拟机加载一个类?
先来一句看不懂的话:所有的Java虚拟机实现必须在每个类或接口被Java程序“ 首次主动使用”时才初始化他们
什么意思 ?
Java程序对类的使用方式可分为两种
– 主动使用
– 被动使用
被动使用以后再讲,这里说说什么是主动使用,java对类的主动使用有六种情况
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName(“com.shengsiyuan.Test”))
- 初始化一个类的子类(先初始化所有的父类,最后初始化本身,接口除外,类初始化的时候,它所实现的接口不会初始化,就算字接口初始化,父接口也不会初始化,只有当程序调用接口的静态变量的时候才会导致接口的初始化)
- Java虚拟机启动时被标明为启动类的类
这里唯一需要解释的可能就是最后一个,什么叫被标记为启动类的类呢
比如我们有一个Hello.java文件,但是里面包含了class Hello ,class Person1 ,class Person2,我们在控制台运行的时候会写java Hello,这个class Hello就是被标记为启动类的类。简单说就是拥有main方法的类
以上的六个活动在第一次发生时,都会促使jvm虚拟机加载类。
除了上述六种情况以外,其他情况都属于类的被动调用,主动调用和被动调用的区别请看下面代码
class Test2{
public static final int n = 2;
static{
System.out.println("test");
}
}
public class Test1 {
public static void main(String[] args) {
System.out.println(Test2.n);
}
}
大家思考一下会出现什么?不管你的答案是什么,正确答案是2。 Test2中的静态代码块是没有运行的,因为Test2并没有初始化
注意,这里的final关键字不可缺少,我们知道变量被关键字fianl修饰之后就不可修改,亦即此变量相当于编译期常量(是相当于并非就是常量),常量在java编译期已经确定,不需要初始化,但是把fianal去掉,或者把 final int n = 2 改为 final int n = new Random()。,运行的结果将变为 test ,2 ,因为n的值为变量或者n值在编译期不能确定,就必须经过初始化才能使用n的值
8、双亲委派机制,使用这个机制的好处?破坏双亲委派机制的场景?如何破坏?
什么是双亲委派机制
当某个类加载器需要加载某个.class文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
类加载器的类别
BootstrapClassLoader(启动类加载器)
c++编写,加载java核心库 java.,构造ExtClassLoader和AppClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作
ExtClassLoader (标准扩展类加载器)
java编写,加载扩展库,如classpath中的jre ,javax.或者
java.ext.dir 指定位置中的类,开发者可以直接使用标准扩展类加载器。
AppClassLoader(系统类加载器)
java编写,加载程序所在的目录,如user.dir所在的位置的class
CustomClassLoader(用户自定义类加载器)
java编写,用户自定义的类加载器,可加载指定路径的class文件
双亲委派机制的作用
1、防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
2、保证核心.class不能被篡改。通过委托方式,不会去篡改核心.clas,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。
如何打破双亲委派模型?
- 自定义类加载器,重写loadClass方法;
- 使用线程上下文类加载器;
双亲委派模型破坏史
1.第一次破坏
由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法唯一逻辑就是去调用自己的loadClass()。
2.第二次破坏
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。
如果基础类又要调用回用户的代码,那该么办?
一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务,
它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
3.第三次破坏
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。
OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:
1)将java.*开头的类委派给父类加载器加载。
2)否则,将委派列表名单内的类委派给父类加载器加载。
3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类加载器失败。
9、了解下tomcat的类加载机制
Tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托。
对于JVM来说:
因此,按照这个过程可以想到,如果同样在CLASSPATH指定的目录中和自己工作目录中存放相同的class,会优先加载CLASSPATH目录中的文件。
1、既然 Tomcat 不遵循双亲委派机制,那么如果我自己定义一个恶意的HashMap,会不会有风险呢?(阿里的面试官问)
答: 显然不会有风险,如果有,Tomcat都运行这么多年了,那群Tomcat大神能不改进吗? tomcat不遵循双亲委派机制,只是自定义的classLoader顺序不同,但顶层还是相同的,
还是要去顶层请求classloader.
2、我们思考一下:Tomcat是个web容器, 那么它要解决什么问题:
- 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
- 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是扯淡的。
- web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
- web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。
再看看我们的问题:Tomcat 如果使用默认的类加载机制行不行?
答案是不行的。为什么?我们看,第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。第三个问题和第一个问题一样。我们再看第四个问题,我们想我们要怎么实现jsp文件的热修改(楼主起的名字),jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
Tomcat 如何实现自己独特的类加载机制?
所以,Tomcat 是怎么实现的呢?牛逼的Tomcat团队已经设计好了。我们看看他们的设计图:
我们看到,前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/、/server/、/shared/(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
• commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
• catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
• sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
• WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;
从图中的委派关系中可以看出:
CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。
WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。
好了,至此,我们已经知道了tomcat为什么要这么设计,以及是如何设计的,那么,tomcat 违背了java 推荐的双亲委派模型了吗?答案是:违背了。 我们前面说过:
双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。
很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。
我们扩展出一个问题:如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?
看了前面的关于破坏双亲委派模型的内容,我们心里有数了,我们可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。
Tomcat类加载
在tomcat中类的加载稍有不同,如下图:
当tomcat启动时,会创建几种类加载器:
1 Bootstrap 引导类加载器
加载JVM启动所需的类,以及标准扩展类(位于jre/lib/ext下)
2 System 系统类加载器
加载tomcat启动的类,比如bootstrap.jar,通常在catalina.bat或者catalina.sh中指定。位于CATALINA_HOME/bin下。
3 Common 通用类加载器
加载tomcat使用以及应用通用的一些类,位于CATALINA_HOME/lib下,比如servlet-api.jar
4 webapp 应用类加载器
每个应用在部署后,都会创建一个唯一的类加载器。该类加载器会加载位于 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。
当应用需要到某个类时,则会按照下面的顺序进行类加载:
1 使用bootstrap引导类加载器加载
2 使用system系统类加载器加载
3 使用应用类加载器在WEB-INF/classes中加载
4 使用应用类加载器在WEB-INF/lib中加载
5 使用common类加载器在CATALINA_HOME/lib中加载
问题扩展
通过对上面tomcat类加载机制的理解,就不难明白 为什么java文件放在Eclipse中的src文件夹下会优先jar包中的class?
这是因为Eclipse中的src文件夹中的文件java以及webContent中的JSP都会在tomcat启动时,被编译成class文件放在 WEB-INF/class 中。
而Eclipse外部引用的jar包,则相当于放在 WEB-INF/lib 中。
因此肯定是 java文件或者JSP文件编译出的class优先加载。
通过这样,我们就可以简单的把java文件放置在src文件夹中,通过对该java文件的修改以及调试,便于学习拥有源码java文件、却没有打包成xxx-source的jar包。
另外呢,开发者也会因为粗心而犯下面的错误。
在 CATALINA_HOME/lib 以及 WEB-INF/lib 中放置了 不同版本的jar包,此时就会导致某些情况下报加载不到类的错误。
还有如果多个应用使用同一jar包文件,当放置了多份,就可能导致 多个应用间 出现类加载不到的错误。
晕了,后面完全看不懂,留个链接
https://www.cnblogs.com/aspirant/p/8991830.html