应届生找工作要点总结(3)JVM

Java虚拟机运行时数据区分为以下几个部分:方法区、虚拟机栈、本地方法栈、堆、程序计数器。如下图所示:

Java堆:线程共享的,唯一目的就是用于存放对象实例,是垃圾收集器管理的主要区域;Java堆可以处于物理上不连续的内存空间中,只需要逻辑上连续即可,就像磁盘上空间存储文件一样。堆上也有可能有部分区域是线程私有的,线程共享的堆中可能划分出多个线程私有的分配缓冲区TLAB。

Java虚拟机栈:线程私有的,每个方法在执行的同时都会创建一个栈帧用于存储局部变量等,局部变量表存放了编译器可知的各种基本数据类型和对象引用;每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

本地方法栈:和虚拟机栈类似,不过它是为Native方法服务;

程序计数器:线程私有的,可以看作是当前线程所执行的字节码的行号指示器,以便线程切换后恢复执行使用;唯一没有OutOfMemoryError的区域。

方法区:线程共享的,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;该区域的内存回收主要是针对常量池的回收和类型的卸载(特别是要注意一些动态字节码框架和自定义ClassLoader的场景下);在HotSpot里经常被称为永久代,在Java 8里已被废除了,被元空间取代;

常量池:byte, short, int, long, char, boolean包装类实现了常量池(float, double没有)。byte, short, char相同级别,不能相互转换,但可强转。

常量池主要存放两大类常量:字面量、符号引用。字面量相当于java语言层面常量的概念,符号引用包括类和接口的全限定名,字段名称和描述名称,方法名称和描述符。

运行时常量池有动态性,java语言并不要常量一定只有在编译时产生,也就是并非预置入class文件中的常量池的内容才能放入常量池,运行期间有新的常量也可以放入池中,比如String的intern方法。优点:对象共享,节约内存空间,节省运行时间。

Hotspot虚拟机对象的创建

首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有没有就执行下一步。

检查通过后,虚拟机会为新生的对象分配内存,主要由两种内存分配策略,一种是指针碰撞,一种是空闲列表。所谓指针碰撞就是把Java堆中的内存一分为二,一边是所有用过的内存(这部分内存不能被分配了),一边是空闲的内存,是可以被分配的,这样的话,在可用于不可用的内存之间会有一个分割点指示器,那么为对象分配内存实际上就是从这个分界点指示器往空闲内存的一边拨动一段空间就可以了。而空闲列表则没有这个假设,已使用的内存与空闲内存可能是交叉在一起的,那么使用指针碰撞的方式分配内存就会产生问题,但是虚拟机维护着一张列表,这张列表记录了哪些区域的内存是可用的,那么在分配内存的时候就从选择可以容纳对象要求大小的内存区域分配给这个对象。

在并发情况下给对象分配内存是线程不安全的,为了解决这个问题,有两种方案:

对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试方法保证更新操作的原子性;把内存分配的动作按照线程划分在不同的空间上进行。即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否用TLAB可以通过-XX:+/-UseTLAB设定。

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前到TLAB分配的时候进行。这一步操作保证了对象的实例在java代码中可以不用赋初值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

接下来虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,例如是否用偏向锁等,对象头会有不同的设置方式。

上面步骤进行完毕之后,在虚拟机的角度,一个新的对象就已经产生了。

对象的内存布局,主要包括三部分的信息:对象头、实例数据和对齐填充。

对象头又包括两部分信息,第一部分用于存储对象自身的运行时数据,比如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等;第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。当然,类型指针不是必须的。可能有疑问,如果没有类型指针,怎么知道这个对象是哪个类的实例呢?答案是知道对象属于哪个类并不一定需要通过对象本身。若对象是一个java数组,则对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组元数据中无法确定数组的大小。

实例数据部分是对象真真好存储的有效信息,也是程序代码中所定义的各种类型的字段内容。

第三部分对齐填充不是必然存在的,只是起到占位符的作用,因为HotspotVM规定对象的起始地址必须是8字节的整数倍。所以很有可能以上两部分的大小不够8字节的整数倍,那么这个字段就可以发挥作用了。

对象的访问定位

主要是通过Java栈中的reference数据,通过这个reference数据只是一个指向对象的引用,那么对象的访问方式就可以不同。目前主流的对象访问方式主要由句柄和直接指针两种。通过句柄访问的话,会在Java堆中划分出一块句柄池,句柄池中的句柄存放了对象的实例数据和类型指针,而reference数据则存放了句柄的地址引用。使用直接指针访问对象,那么reference数据存放的就是对象的地址。

使用句柄访问的最大好处是reference中存储的稳定的句柄地址,当对象的地址发生了改变可以不用去关心。而直接指针的最大好处是速度更快,在于节省了一次指针定位的时间。

class文件为二进制字节流,以8位字节为基础。每个class文件的头4个字节称为魔数,他的唯一作用是确定这个文件是否为一个能被虚拟机接受的class文件。紧接着魔数的4个字节是class文件的版本号(5,6字节为次版本号,7,8字节为主版本号)。紧接着主版本号是常量池入口。紧接着两个字节为访问标志。类索引,父类索引,接口索引集合。字段表。方法表。属性表(java程序方法体中的代码经过java编译后,最终变为字节码指令存在code属性中)。java虚拟机操作码只有一个字节。

哪些对象可以作为GCroots:虚拟机栈(帧栈中的本地变量表)中的引用对象。方法区中类的静态属性引用的对象。方法区中常量引用的对象。本地方法栈中JNI(Java Native Interface)引用的对象。

对象是否可用以及引用类型

由于引用计数法无法解决循环引用的问题,所以一般都是使用可达性分析来判断的,即通过一系列称为“GC Roots”的对象(比如虚拟机栈引用的对象、方法区中的类静态属性和常量引用对象)作为起点,从这些节点一直往下搜索,走过的路径称为引用链;而那些没有与引用链相连的对象即为不可达,会被回收;

可以通过覆盖finalize方法来实现对象的“自救”,避免在标记后被回收,但通常不建议这么做;

对象的引用类型可分为:强引用、软引用(在内存溢出前会将这种类型的对象进行第二次回收)、弱引用(弱引用对象只能生存到下次垃圾回收之前)、虚引用(不会对生存时间存在影响,也无法通过它获取对象,主要目的就是在回收时收到一个系统通知);

永久代的垃圾回收包括两部分:废弃常量和无用的类。

废弃常量的回收比较好理解,因为只要没有任何对象引用常量池中的某个对象,那么这个对象就会被回收(废弃常量回收的是运行时常量池中的对象,所以只需要一次标记就好)。

类要满足下面3个条件才是无用类:所有实例被回收;加载该类的classloader被回收;该对象的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射调用该类的方法。

GC算法

停止-复制算法:将可用内存按照容量划分为大小相等的两块,每次只能使用其中的一块。当这一块内存用完了,则将还存活的对象复制到另一块内存上,然后把已经使用过的内存空间一次清理掉。(商业虚拟机:将内存分为一块较大的eden空间和两块较小的survivor空间,默认比例是8:1:1,即每次新生代中内存空间为整个新生代的90%,每次使用eden和其中一个survivor,最后清理掉eden和刚才用过的survivor,若另一块survivor空间没有足够的内存空间存放上次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代)。新创建的对象都会被分配到Eden区,这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时(15),就会被移动到年老代中。“From”和“To”会交换他们的角色。

标记-清除算法缺陷:产生大量不连续的内存碎片;标记和清除的效率都不高。

标记-整理算法:标记过程和“标记-清除”一样,但后续不是直接对可回收对象进行清除,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。

分代收集:新生代:停止-复制;老年代:标记-整理或标记-清除。由于年轻代的大小配置通常只占整个GC堆的较小部分,而且较高的对象死亡率(或者说较低的对象存活率)让它非常适合使用停止-复制算法来收集,这样就不但能降低单次GC的时间长度,还可以提高GC的工作效率。

新生代,老年代-------堆。永久代-------方法区。

垃圾收集器,前三个是新生代,后三个是老年代。

serial收集器:单线程(不仅是它使用一个cpu或一个垃圾收集线程去完成垃圾收集工作,更重要是它在进行垃圾收集的时,必须暂停其他所有工作线程,知道垃圾收集结束)。对于运行在client模式下是虚拟机是很好是选择。停止-复制。

parNew收集器:serial的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器一致。是许多运行在server模式下的虚拟机首选的新生代收集器。停止-复制。

parallel scaverge:吞吐量优先收集器,目标是达到一个可控制的吞吐量,适合在后台运算,没有太多是交互。停止-复制。

serial old:serial的老年代版本,单线程,标记-整理。

parallel old:parallel的老年代版本,多线程,标记-清除。

cms:一种以获取最短收集停顿时间为目标的收集器“标记-清除”,有四个过程:初始标记(查找直接与gcroots链接的对象)并发标记(tracing过程)重新标记(因为并发标记时有用户线程在执行,标记结果可能有变化)并发清除;其中初始标记和重新标记阶段,要“stop the world”(停止工作线程)。

优点:并发收集,低停顿;缺点:不能处理浮动垃圾(在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们)。对CPU资源太敏感,虽然在并发标记阶段用户线程没有暂停,但是由于收集器占用了一部分CPU资源,导致程序的响应速度变慢。会导致大量的空间碎片的产生,大对象就没办法给其分配内存,所以虚拟机就会触发一次Full GC这个问题的解决是通过控制参数-XX:+UseCMSCompactAtFullCollection,用于在CMS垃圾收集器顶不住要进行FullGC的时候开启空间碎片的合并整理过程。

G1收集器:特点:并行与并发,分代收集(仍然保留了分代的概念),空间整合(整体上属于“标记-整理”算法,不会导致空间碎片),可预测的停顿(比CMS更先进的地方在于能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒)。此外,G1收集器将Java堆划分为多个大小相等的Region(独立区域),新生代与老年代都是一部分Region的集合,G1的收集范围则是这一个个Region。

G1的工作过程如下:初始标记(Initial Marking)并发标记(Concurrent Marking)最终标记(Final Marking)筛选回收(Live Data Counting and Evacuation)。

使用serial, parnew等带compat压缩过程的收集器时,常常采用的分配方法是指针碰撞。而使用cms这种基于mark-sweep清理算法的收集器时,通常采用空闲列表。

程序执行并非在所有地方都能停下来开始GC,只能在到达安全点才可以。

垃圾回收器是自动运行的,一般情况下,无需显示的请求垃圾收集器,调用system类的gc方法可以运行垃圾收集器,但这样并不能保证立即回收指定对象。调用system.gc(),这时一个不确定的方法。java中并不能保证每次调用该方法就一定能启动垃圾收集,他不过是会向jvm发送这样一个申请,到底是否真正执行垃圾收集,都是未知数。

垃圾收集器并不是一个独立的平台,它具有平台依赖。一段程序可以建议垃圾回收执行,但不能强迫它执行。

服务的虚拟机参数

-server --启用能够执行优化的编译器,显著提高服务器的性能

-Xmx4000M --堆最大值

-Xms4000M --堆初始大小

-Xmn600M --年轻代大小

-XX:PermSize=200M --持久代初始大小

-XX:MaxPermSize=200M --持久代最大值,默认64

-XX:PretenureSizeThreshold=3145728 // 表示对象大于3145728(3M)时直接进入老年代分配,这里只能以字节作为单位

-Xss256K --每个线程的栈大小

-XX:+DisableExplicitGC --关闭System.gc()

-XX:SurvivorRatio=1 --年轻代中Eden区与两个Survivor区的比值

-XX:+UseConcMarkSweepGC --使用CMS内存收集

-XX:+UseParNewGC --设置年轻代为并行收集

-XX:+CMSParallelRemarkEnabled --降低标记停顿

-XX:+UseCMSCompactAtFullCollection --在FULL GC的时候,对年老代进行压缩,可能会影响性能,但是可以消除碎片

-XX:CMSFullGCsBeforeCompaction=0 --此值设置运行多少次GC以后对内存空间进行压缩、整理

-XX:+CMSClassUnloadingEnabled --回收动态生成的代理类 SEE:http://stackoverflow.com/questions/3334911/what-does-jvm-flag-cmsclassunloadingenabled-actually-do

-XX:LargePageSizeInBytes=128M --内存页的大小不可设置过大, 会影响Perm的大小

-XX:+UseFastAccessorMethods --原始类型的快速优化

-XX:+UseCMSInitiatingOccupancyOnly --使用手动定义初始化定义开始CMS收集,禁止hostspot自行触发CMS GC

-XX:CMSInitiatingOccupancyFraction=80 --使用cms作为垃圾回收,使用80%后开始CMS收集

-XX:SoftRefLRUPolicyMSPerMB=0 --每兆堆空闲空间中SoftReference的存活时间

-XX:+PrintGCDetails --输出GC日志详情信息

-XX:+PrintGCApplicationStoppedTime --输出垃圾回收期间程序暂停的时间

-Xloggc:$WEB_APP_HOME/.tomcat/logs/gc.log --把相关日志信息记录到文件以便分析.

-XX:+HeapDumpOnOutOfMemoryError --发生内存溢出时生成heapdump文件

-XX:HeapDumpPath=$WEB_APP_HOME/.tomcat/logs/heapdump.hprof --heapdump文件地址

对象内存的分配

大多数情况下,对象在新生代eden区中分配,当eden区中没有足够的内存空间进行分配时,虚拟机将发起一次minor gc(minor gc:发生在新生代是垃圾收集动作,一般回收速度比较快。full gc:发生在老年代的gc)。

大对象直接进入老年代,长期存活的对象将进入老年代。

若在survivor空间中相同年龄的所有对象大小的总和>survivor空间的一半,则年龄>=该年龄的对象直接进入老年代,无需等到MaxTeuringThreshold(默认15)的要求。虚拟机给每个对象定义一个对象年龄计数器,若对象在eden出生并经过第一次minor gc后依然存活,并且能被survivor容纳的话,将被移到survivor空间,并且对象年龄设为1。对象在survivor中每熬过一次minor gc,年龄就加1,当年龄达到一定程度(默认15)就会晋升到老年代。

使用wpmap知道哪些地方存着对象的引用。jps:列出正在运行的虚拟机进程。jstat:虚拟机运行状态信息。类加载,垃圾回收,运行期编译状况。jinfo:虚拟机参数。jmap:堆转储快照heapdump/dump文件。jhat:分析heapdump文件。jstack:用于生成虚拟机当时时刻的线程快照。jstack:可以观察到jvm中当前所有线程的运行情况和线程当前状态。如果现在运行的java程序呈现hung的状态,jstack是非常有用的。命令格式:jstack 进程pid。当程序出现死锁的时候,使用命令:jstack 进程ID > jstack.log,然后在jstack.log文件中,搜索关键字“BLOCKED”,定位到引起死锁的地方。

空间分配担保

在发生minor gc之前,虚拟机会检测老年代最大可用连续空间是否>新生代所有对象总空间,若这个条件成立,那么minor gc可以保证是安全的。若不成立,则虚拟机会查看handlepromotionfailure设置值是否允许担保失败。若允许,那么会继续检测老年代最大可用连续空间>历次晋升到老年代对象的平均大小。若大于,则将尝试进行一次minor gc,尽管这个minor gc是有风险的。若小于handlepromotionfailure设置不允许冒险,则这时要改为进行一次full gc。

Full GC触发条件:调用System.gc时,系统建议执行Full GC,但是不必然执行。老年代空间不足。方法区空间不足。通过Minor GC后进入老年代的平均大小大于老年代的可用内存。由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

持久代java堆溢出:使用cglib技术直接操作字节码运行,产生大量的动态类;老年代溢出:上万次字符串处理,创建上万个对象或在一段代码内申请大量内存。

OOM(out of memory)

Java堆溢出;虚拟机栈和本地方法栈溢出;方法区和运行常量池溢出;本机直接内存溢出。程序计数器是唯一一个在java虚拟机规范中没有规定任何oom情况的区域。

在java虚拟机规范中,对于java虚拟机栈,规定了两种异常:若线程请求栈深度>虚拟机所允许的深度,则抛出stackoverflowerror异常;若虚拟机可以动态扩展,若扩展时无法申请到足够的内存空间,则抛出oom异常。

堆上OOM测试:// -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGcDetails

List<OOMObject> list = new ArrayList<>();

while (true){

list.add(new OOMObject());

}

直接内存不是运行时数据区的一部分。堆内存最大2g占了3g原因:直接内存。

内存泄漏就是存在一些被分配的对象,这些对象存在以下特点:对象是可达的;即在有向图中,存在通路可以与其相连;对象是无用的,即程序以后不会再使用这些对象。这些对象不会被gc所回收,然而他们却占用内存。发生泄漏的第一个迹象通常就是:在程序中出现的OOM。linux查看内存泄漏方式:cat /proc/[pid]/status

内存溢出:程序在申请内存的时候,没有足够的内存空间供其使用。内存泄漏:分配出的内存不再使用,但无法回收。

通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到虚拟机外部去实现,实现这个动作的代码模块称为类加载器;这种设计给Java语言带来了非常强大的灵活性;

类加载生命周期:类从加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载-验证-准备-解析-初始化-使用-卸载,其中验证-准备-解析称为链接。

在遇到下列情况时,若没有初始化,则需要先触发其初始化(加载-验证-准备需要在此之前):使用new关键字实例化对象;读取或设置一个类的静态字段;调用一个类的静态方法。使用java.lang.reflect包方法对类进行反射(在运行时获取类或对象的相关信息)调用时,若类未进行初始化,则需要触发其初始化。当初始化一个类时,若发现其父类还没有进行初始化,则要先触发其父类初始化。当虚拟机启动时,用户需要定制一个执行的主类(main),虚拟机会先初始化这个类。

在加载阶段,虚拟机需要完成下面三件事:通过一个类的全限定名获取定义此类的二进制字节流。将这个字节流所表示的静态存储结构转化为方法区运行时数据结构。在内存中生成一个代表这个类的class对象,作为方法区的各种数据的访问入口。

验证的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身是安全。验证阶段大致会完成下面4个阶段的检验动作:文件格式验证;元数据验证;字节码验证;符号引用验证(字节码验证将对类的方法进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的事,一个类方法体的字节码没有通过字节码验证,一定有问题,通过了也不一定安全)。

准备阶段是正式为类变量分配内存并设置变量的初始化值的阶段,这些变量所使用的内存将在方法区中进行分配。(不是实例变量,且是初始值,若public static int a = 123;准备阶段a的值为0而不是123,要在初始化之后才变为123,但若被final修饰,public static final int a = 123;在准备阶段就变为123)。

解析阶段是虚拟机将常量池中的符号引用变为直接引用的过程。符号引用的目标不一定已经加载入内存,直接引用的目标一定已经载入内存。

当运行一个程序时,jvm启动,运行bootstrap classloader,该classloader加载核心api(ext classloader和app classloader也在此时被加载),然后调用ext classloader加载扩展api,最后app classloader加载classpath目录下定义的class,这就是一个程序最基本的加载流程。

通过classloader加载类实际上就是加载的时候并不对该类进行解析,因此不会初始化,而class类的forname方法则相反,使用forname方法加载的时候会将class进行解析与初始化。

到初始化阶段才是真正开始执行定义的java程序代码(或者说字节码)。初始化阶段是执行类的构造器(init)方法的过程。<init>方法是由编译器自动收集类的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的。编译器收集的顺序是由语义在源文件中出现的顺序决定的。<clinit>方法与类的构造函数不同,不需要显式的调用父类的构造器,虚拟机会保证在子类的<clinit>方法执行前,父类的<clinit>方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>方法肯定是java.lang.object。由于父类的<clinit>方法先执行,就意味着父类的静态语句块优于子类的变量赋值。虚拟机会保证一个类的<clinit>方法在多线程环境中被正确的加锁同步。若多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的init方法,其他线程都要阻塞等待,直到活动执行<clinit>完毕。

初始化顺序:成员变量默认初始化;调用类的构造器,一层层调用;按声明顺序调用成员的初始化方法;调用子类构造器主体。

静态代码块优于构造函数的执行,只能访问在静态代码块之前的变量,在它之后的变量,块中可以复制,但不能使用。一个类可以一个或多个静态代码块。静态代码块在被类加载时执行,优于构造函数的执行,并且按照静态代码块在类中的顺序执行。接口中不能有。

比较两个类是否相同,只有这两个类是由同一个加载器加载的时候才有意义,否则即使这两个类来源于同一个class文件,被同一个虚拟机加载,只要他们的加载器不同,他们就是不同的类。

从java虚拟机的角度说,只存在两种不同的加载器:一种是启动器加载器,这个类加载器使用c++实现,是虚拟机自身的一部分。另一种就是所有其他类的加载器,这些类加载器都有java实现,且全部继承自java.lang.ClassLoader。

从java开发人员角度,类加载器分为:

启动器加载器:这个加载器负责把<JAVA_HOME>/lib目录中或者-Xbootclasspath下的类库加载到虚拟机内存中,启动器加载器无法被java程序直接引用。

扩展类加载器:负责加载<JAVA_HOME>/lib/ext下或者java.ext.dirs系统变量指定的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器:负责加载用户路径classpath上指定的类库,开发者可以直接使用这个类加载器,若应用程序中没有定义过自己的类加载器,一般情况下,这个就是程序默认的类加载器。

双亲委派型:若一个类加载器收到了类加载的请求,它首先不会自己区尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层的加载器都是如此,因此所有加载请求最终都应该传送到顶级的启动器加载器。只有当父类加载器无法自己加载时(他的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委派的好处:eg,object类。它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都是委派给处于顶端的启动器加载器加载,因此object类在程序的各种加载环境中都是同一个类。

双亲委派模式是通过loadclass方法实现:先检查类是否被加载过,若没有,则调用父类加载器的loadclass方法,若父类加载器为空,则使用启动类加载器为父类加载器。若父类加载器加载失败,先抛出classnotfoundexception,然后调用自己的findclass方法进行加载。

findLoadedClass(在虚拟机内存中查找是否已经加载过此类...类缓存的主要问题所在)

parent.loadClass(先让上一层加载器进行加载)

findClass(调用此类加载器所实现的findClass方法进行加载)

假设A加载成功了,那么该类就会缓存在当前的类加载器实例对象C中,key是(A,C)(其中A是类的全类名,C是加载A的类加载器对象实例),value是对应的java.lang.Class对象。

定义一个对象的过程:加载类,分配对象大小,默认初始化(<init>方法),把对象按程序员的意愿初始化。

要实现自定义的类加载器:只需要继承java.lang.classloader。若自己编写一个类放在java.lang.object并放在classpath下面,可以正常编译,但永远无法被加载。

Java内存模型

虚拟机规范视图通过JMM来屏蔽掉各种硬件和操作系统的内存访问差异,主要目标是定义程序中各个变量的访问限制,即在虚拟机将变量存储到内存和从内存中取出变量这样的底层细节;

JMM:通俗地讲,就是描述Java中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

主内存:保存了所有的变量。

共享变量:如果一个变量被多个线程使用,那么这个变量会在每个线程的工作内存中保有一个副本,这种变量就是共享变量。

工作内存:每个线程都有自己的工作内存,线程独享,保存了线程用到了变量的副本(主内存共享变量的一份拷贝)。工作内存负责与线程交互,也负责与主内存交互。

线程对共享内存的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写;不同线程无法直接访问其他线程工作内存中的变量,因此共享变量的值传递需要通过主内存完成。

可见性(主内存和工作内存)、原子性(volatile的long是具备原子性的)、有序性(happen—before规则);

线程交叉执行:主要指线程调度。

指令重排序:为了发挥CPU性能,指令执行顺序可能与书写的不同,分为编译器优化的重排序(编译器优化),指令集并行重排序(处理器优化),内存系统的重排序(处理器优化)。

共享变量更新:如果想让线程A对共享变量的修改被线程B看到,需要以下步骤:把线程A的工作内存中更新过的变量刷新到主内存中,再将主内存中最新的共享变量的值刷新到线程B的工作内存中。如果更新不及时,则会导致共享变量的不可见,数据不准确,线程不安全。

说到重排序,就不得不说一下as-if-serial语义:无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致。(编译器,运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)

jvm中,一个字节一下的整形数据byte,-128到127会在jvm启动时加载入内存,除非用new Integer()显式的创建对象,否在都是同一个对象。Integer的value方法返回一个对象,先判断传入的参数是否在-128到127之间,若已经存在引用,则直接引用,否在返回new Integer(n)。Integer i1 = 59; int i2 = 59; Integer i3 = Integer.valueOf(59); Integer i4 = new Integer(59); i1== i2是true;i1 == i3是true;i3 == i4是false;i4 == i2是true,i4自动拆箱。Integer i = 100;相当于编译器为我们做了Integer i = Integer.value(100);

在编译代码的过程中,帧栈中需要多大的局部变量表,多深的操作数栈已经完全确定了,并且写入到了方法的code属性中,因此,一个帧栈需要分配多少内存,不会受到运行期变量的影响,而仅仅取决于具体虚拟机的实现。

局部变量表以slot为单位,long和double占2个slot,第0位索引的slot默认为用于传递方法所属对象实例调用,this。若一个局部变量定义了,但没有赋初始值是不能使用的。

操作数栈中元素的类型必须与字节码指令的序列严格匹配。

分派是多态性的体现,Java虚拟机底层提供了我们开发中“重写”和“重载”的底层实现。其中重载属于静态分派,而重写则是动态分派的过程。除了使用分派的方式对方法进行调用之外,还可以使用解析调用,解析调用是在编译期间就已经确定了,在类装载的解析阶段就会把符号引用转化为直接引用,不会延迟到运行期间再去完成。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分配发生在编译阶段,因此确定静态分派的动作,实际上不时有虚拟机执行。

JIT编译,在虚拟机内部,把字节码转换为机器码。

java编译器是由java语言写的程序。编译的三个过程:解析与填充符号表的过程。插入或注解处理器的注解处理过程。分析与字节码生成过程。

热点代码需要即时编译,JIT编译,判断一段代码是否是热点代码叫热点探测。

基于采样的热点探测:采用这种方法的虚拟机会周期性的检查各个线程的栈顶,若发现某个或某些方法经常出现在栈顶,则该方法为热点方法。

基于计数器的热点探测:采用这种方法的虚拟机会为每个方法建立计数器,统计方法的执行次数,若执行次数超过一次阈值,则认为是热点方法。

即时编译优化技术:公共子表达式消除:如果一个表达式E已经计算过了,且从先前的计算到现在,E中所有变量值都未变,则E的这次出现就成了公共子表达式,对于这种表达式,没有必要对他再花时间进行计算,只需要直接用前面计算的表达式结果替代E即可。数组边界检查消除。方法内联。逃逸分析。

JVM关闭:

正常关闭:当最后一个非守护线程结束或调用了system.exit或通过其他特定于平台的方式,比如ctrl+c。

强制关闭:调用runtime.halt方法,或在操作系统中直接kill(发送single信号)掉jvm进程。

异常关闭:运行中遇到runtimeException等。

在某些情况下,我们需要的jvm关闭时做一些扫尾的工作,比如删除临时文件、停止日志服务。为此jvm提供了关闭钩子(shutdown hocks)来做这些事。runtime类封装java应用运行时的环境,每个Java应用程序都有一个runtime类实例,使程序能与运行环境相连。关闭钩子本质上是一个线程(也称hock线程),可通过runtime的addshutdownhock(Thread hock)向jvm注册一个关闭钩子。hock线程在jvm正常关闭时执行,强制关闭时不执行。对于jvm中注册的多个关闭钩子,他们会并发执行,jvm不保证他们的执行顺序。

线程安全的概念限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说完全没有区别。

线程安全的程度:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变:不可变的对象一定是线程安全的,只要一个不可变对象被正确地构建出来(没有发生this引用逃逸情况),那其外部的可见状态永远也不会改变 ,永远也不会看到它在多个线程之中处于不一致的状态。如Integer类,内部状态变量value定义为final来保障状态不变。

绝对线程安全:要达到不管运行的环境如何,调用者都不需要任何额外的同步措施。在Java API中标注自己是线程安全的类,大多数都不是绝对线程安全的。

相对线程安全:通常意义上的线程安全,需要保证对这个对象的单独操作是线程安全的,在调用的时候不需要做额外的保障措施,但对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合。

线程兼容:指对象本身不是线程安全的,但是可以通过调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,Java API大部分类属于线程兼容,如ArrayList、HashMap。

线程对立:无论调用端是否采取了同步措施,都无法在多线程环境下并发使用的代码。有害的,应该尽量避免,如Thread类的suspend()和resume()方法,如果两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,并且并发进行,无论调用时是否进行了同步,目标线程都存在死锁风险,所以这两个方法已经被声明废弃。

线程安全的实现方法

互斥同步,同步指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用,互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。

在Java中,最基本的同步手段是synchronized关键字,synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果明确指定了对象参数,那就是这个对象的reference,如果没有明确指定,会根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

在执行monitorenter指令时,首先尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放,如果获取对象的锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

synchronized同步块对一条线程是可重入的,不会出现自己把自己锁死的问题,其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

Java的线程是映射到系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态,因此状态转换需要耗费很多处理器时间,对于代码简单的同步块,状态转换消耗的时间有可能比用户代码执行的时间还要长,所以synchronized是Java中一个重量级的操作,虚拟机本身会进行一些优化,如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁切入到核心态之中。

除了synchronized外,还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,synchronized表现为原生语法层面的互斥锁,ReentrantLock表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成)。ReentrantLock增加一些高级功能,主要有以下3项:等待可中断、可实现公平锁、锁可以绑定多个条件。

等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,对处理执行时间非常长的同步块很有帮助。

公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁,而非公平锁在锁释放时,任何一个等待锁的线程都有机会获得锁,synchronized的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。

锁绑定多个条件:指一个ReentrantLock对象可以同时绑定多个Condition对象,而synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁,而ReentrantLock只需多次调用newCondition()方法即可。

非阻塞同步:互斥同步主要的问题是进行线程阻塞和唤醒所带来的性能问题,因此这种同步称为阻塞同步。互斥同步属于悲观并发策略,总认为只要不去做同步措施,那就肯定会出现问题,无论共享数据是否真的出现竞争,都要进行加锁。基于硬件指令集的发展,有了另外一种选择:基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生冲突,那就再采取其他补救措施(常见的补救措施是不断重试,直到成功为止),因为这种策略的许多实现不需要把线程挂起,因此称为非阻塞同步。

比较并交换(Compare-and-Swap):CAS指令需要3个操作数,分别是内存位置(简单理解为变量的内存地址,用V表示)、旧的预期值(A表示)和新值(B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则不执行更新,但是无论是否更新V的值,都会返回V的旧值,整个处理过程是一个原子操作。

ABA问题,CAS从语义上来说并不完美,存在一个逻辑漏洞,如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那么CAS认为它的值没被改变过,但这段期间可能A值曾经被改成B,后来又改回A。有一个带标记的原子引用类AtomicStampedReference,可以通过控制变量的版本来保证CAS的正确性,不过大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

线程本地存储,java.lang.ThreadLocal类可以实现线程本地存储功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值得K-V值对,ThreadLocal对象是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象包含一个独一无二的threadLocalHashCode值,使用这个值可以在线程K-V值对中找回对应的本地线程变量。

锁优化

自旋锁与自适应自旋:共享数据的锁定状态可能只持续很短的一段时间,为了这段时间去挂起和恢复线程不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,可以让后面请求锁的线程“等待一段时间”,但不放弃处理器的执行时间,看看持有锁的线程是否很快释放锁,为了让线程等待,让线程执行一个忙循环(自旋),这项技术就是自旋锁。

JDK 1.6引入自适应自旋,自旋时间不固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机会认为这次自旋也很可能再次成功,进而允许自旋等待持续相对更长的时间,另外,如果对于某个锁,自旋很少成功,那么在以后获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

锁消除:指在虚拟机即时编译器运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除,判定依据来源于逃逸分析的数据支持,如果一段代码,堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为是线程私有的,同步加锁无需进行。

锁粗化:如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁出现在循环体中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机检测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部。

轻量级锁: 在代码进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝,称为Displaced Mark Word,然后使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果更新动作成功,那么线程就拥有了该对象的锁,并且对象Mark Word的锁标志位转变成“00”,表示处于轻量级锁定状态。如果更新动作失败,先检查对象的Mark Word是否指向当前线程的栈帧,如果是,就可以直接进入同步块继续执行,否则说明这个锁对象被其他线程抢占了,如果有两条以上线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值转变为“10”。解锁过程也是通过CAS操作进行,如果对象的Mark Word仍然指向线程的锁记录,就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,说明有其他线程尝试过获取锁,那就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁使用CAS操作避免使用互斥量的开销,如果存在锁竞争,除了互斥量的开销,还额外发生了CAS操作。

偏向锁:在无竞争情况下把整个同步消除掉,连CAS操作都不做了。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要同步。

JVM调优:CPU使用率与Load值偏大(Thread count以及GC count)、关键接口响应时间很慢(GC time以及GC log中的STW的时间)、发生Full GC或者Old CMS GC非常频繁(内存泄露);JVM停顿(尽量避免Full GC、关闭偏向锁、输出GC日志到内存文件系统、关闭JVM输出的jstat日志);

将Java性能优化分为4个层级:应用层、数据库层、框架层、JVM层。每层优化难度逐级增加,涉及的知识和解决的问题也会不同。比如应用层需要理解代码逻辑,通过Java线程栈定位有问题代码行等;数据库层面需要分析SQL、定位死锁等;框架层需要懂源代码,理解框架机制;JVM 层需要对GC的类型和工作机制有深入了解,对各种 JVM 参数作用了然于胸;

围绕Java性能优化,有两种最基本的分析方法:现场分析法和事后分析法。现场分析法通过保留现场,再采用诊断工具分析定位。现场分析对线上影响较大,部分场景不太合适。事后分析法需要尽可能多收集现场数据,然后立即恢复服务,同时针对收集的现场数据进行事后分析和复现。

OS 的诊断主要关注的是 CPU、Memory、I/O 三个方面。top、vmstat、 free –m、iostat;常用的Java应用诊断包括线程、堆栈、GC 等方面的诊断,可以使用jstack 、jstat、jmap;

猜你喜欢

转载自blog.csdn.net/SEUSUNJM/article/details/86534627