【JVM】关于JVM

目录

 

1.JVM的内存模型是什么?

1.程序计数器

2.虚拟机栈

3.本地方法栈

4.堆

5.方法区

2.堆、方法区和栈之间的关系

3.常用Java虚拟机参数

最大堆和初始堆的设置

新生代的设置

堆溢出

方法区

栈配置

直接内存配置

4.虚拟机的工作模式---Client和Server

Client模式:

Server:

对比:

5.垃圾回收算法

1.引用计数法

2.标记清除 

3.标记压缩 - 老年代回收算法

4.复制 - 新生代回收

5.分代算法

卡表

6.分区算法

6. GC用的引用可达性分析算法中,哪些对象可作为GC Roots对象?

7.引用和可触及性的强度

强引用 - 不回收的引用

软引用-可被回收的引用

补充:内存泄漏和内存溢出的区别

 弱引用-发现就回收

补充:软引用和弱引用适合什么样的场景?

虚引用-对象回收跟踪

8.Stop-The-World 

9.垃圾收集器

1.串行回收器

 2.并行回收器

3.CMS回收器 (Concurrent Mark Sweep 并发标记清除)

4.G1回收器

CMS和G1的区别:

10.Full GC 的触发条件

11.内存分配策略

12.TLAB 线程本地分配缓存

13.String常量池的位置

14.锁在JVM中的实现和优化

1.偏向锁

2.轻量级锁

3.锁膨胀

4.自旋锁

5.锁消除

15.锁在应用层的优化思路

1.减少锁持有时间。

2.减小锁粒度

插一条:为啥1.8不用Segment了?

3.锁分离

4.锁粗化

16.无锁

为什么有LongAdder?

17.类初始化时机

18.类加载机制

19.类加载器

20.类加载器分类:

21.双亲委派模型

22.有没有可能父类和子类加载一个类?

23.突破双亲模式

既然 Tomcat 不遵循双亲委派机制,那么如果我自己定义一个恶意的HashMap,会不会有风险呢?(阿里的面试官问)

Tomcat是个web容器, 那么它要解决什么问题: 

Tomcat 如果使用默认的类加载机制行不行? 

Tomcat 如何实现自己独特的类加载机制?

如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?(父类加载器无法访问底层的加载器所加载的类,怎么办?)


1.JVM的内存模型是什么?

这个问题几乎每次都会被问到,要注意的是它和JMM不是一个概念。

1.程序计数器

线程私有,JVM会为每个Java线程创建一个程序计数器,任意时刻,一个线程总是在执行一个方法,这个方法被称为当前方法,如果当前方法不是Native方法,程序计数器就会指向当前正在被执行的指令,如果是Native方法,那么值就是undefined。

2.虚拟机栈

线程私有,每个JVM线程都有一个私有的栈,它在线程创建时被创建,栈中保存着栈帧。
局部变量表(GC Root)
操作数栈:保存计算过程的中间结果,计算过程中变量临时的存储空间。
帧数据区:存放常量池的指针,正常方法的返回和异常处理(异常处理表)。
栈上分配:JVM优化技术,对于那些线程私有的对象,可以将它们打散分配在栈上而不是堆上,好处是可以在函数调用结束后自行销毁,不需要垃圾回收器的介入,从而提高性能。

3.本地方法栈

本地方法栈主要为本地方法服务,本地方法指的是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

4.堆

所有对象实例都在这里分配内存

堆可以处于物理上不连续的内存,只要逻辑上是连续的即可,但是堆中的对象必须是连续分布的。随着JIT编译器的发展与逃逸分析技术逐渐成熟,所有对象都在堆中分配内存也不那么绝对了。

5.方法区

方法区是所有线程共享的,用来保存类信息,比如类的字段、方法、常量池等。

JDK1.7方法可以理解为永久区(Perm)。可以使用参数-XX:PermSize 和-XX:MaxPermSize(默认64MB)指定。如果系统使用了动态代理,有可能会在运行时生成大量的类,因此需要设置一个合理地永久区大小,确保不会发生永久区内存溢出。

JDK1.8中,没有永久区,而是用元数据区(Metaspace)来取代,元数据区的大小可以用参数-XX:MaxMetaspaceSize指定。这是一个块堆外的直接内存。与永久区不同,如果不指定大小,默认情况下,虚拟机会消耗所有可用的系统内存。

2.堆、方法区和栈之间的关系

public class SimpleHeap {
    private int id;
    public SimpleHeap(int id){
        this.id = id;
    }
    public void show(){
        System.out.println("ID = " + id);
    }

    public static void main(String[] args) {
        SimpleHeap s1 = new SimpleHeap(1);
        SimpleHeap s2 = new SimpleHeap(2);
        s1.show();
        s2.show();
    }
}

 

SimpleHeap类信息存放在方法区中,SimpleHeap实例分配在堆中,main方法中的局部变量s1,s2在虚拟机栈中,并指向堆中的实例。

3.常用Java虚拟机参数

最大堆和初始堆的设置

-Xms512m初始堆大小 
-Xmx512m最大堆大小。
推荐将最大堆和初始堆设置相等,可以减少运行时垃圾回收的次数,提高性能。

新生代的设置

 -Xmn可以设置新生代大小,设置较大的新生代会减小老年代的大小,新生代大小一般设置为整个堆空间的1/3或1/4

-XX:SurvivorRatio用来设置Eden和2个survivor区(from、to)的比例关系.
-XX:SurvivorRatio = eden/from = eden/to

基本策略:尽可能将对象留在新生代,减少老年代GC的次数。

-XX:NewRatio = 老年代/新生代

堆溢出

java.lang.OutOfMemoryError:Java heap space

发生OOM会导致系统被迫退出,需要在发生错误时,获取尽可能多的现场信息

-XX:HeapDumpOnOutOfMemoryError,在内存溢出时导出整个堆的信息。
-XX:HeapDumpPath,指定存放路径。

除了堆,还有一些内存用于方法区、线程栈和直接内存,他们与堆内存相对独立,不过有效合理配置这些内存参数,对系统性能和稳定性也有重要作用。

方法区

1.7  -XX:PermSize初始永久区大小     -XX:MaxPermSize最大永久区

1.8  -XX:MaxMetaspaceSize 元数据区的最大值。

栈配置

-Xss 指定线程最大栈空间,决定了函数调用的最大深度,通过调大-Xss的值,可以获得更深的调用层次 

相同栈容量下,局部变量少的方法可以支持更深的调用。

直接内存配置

NIO广泛使用后,直接内存的使用变得非常普遍,直接内存跳过了堆使Java程序可以直接访问原生堆空间,因此也从一定程度加快了内存空间的访问速度。

 -XX:MaxDirectMemorySize 最大可用直接内存,如果不设置,默认值为最大堆空间,即-Xmx。

当直接内存使用量达到-XX:MaxDirectMemorySize时,会触发垃圾回收,如果不能释放足够空间,直接内存溢出会引起OOM。

4.虚拟机的工作模式---Client和Server

默认情况下虚拟机会根据当前系统环境自动选择运行模式。
使用 -version 可以查看当前模式。

Client模式:

使用参数-client可指定Client模式。

Server:

使用参数-server 可指定Server模式。

对比:

  • 相比Client模式,Server模式启动比较慢,因为Server模式会尝试收集更多系统性能信息,使用更复杂的优化算法对程序进行优化。
  • 因此,当系统完全启动并进入运行稳定期后,Server模式的执行速度会远快于Client模式
  • 后台长期运行的系统,使用-server启动对性能有不小的帮助。
  • 对于用户界面程序,运行时间不长,追求启动速度,可以选Client模式。
  • Client模式下,CompileThreshold默认值1500方法被调用1500次后,会进行JIT编译
  • Server模式下,CompileThreshold默认值为10000,系统更有可能解释执行,而一旦进行编译,Server模式的优化效果会好于Client模式

5.垃圾回收算法

垃圾回收:将内存中不再使用的对象占用的内存释放。

1.引用计数法

实现:为每个对象分配一个整型计数器,当对象被引用就加1,引用失效就减1,当计数器为0就可以回收。

问题

1.无法处理循环引用的情况,因此Java垃圾回收器中,没有使用这种算法。
2.每次引用产生和消除时,需要进行加减操作,对系统性能有一定影响。

2.标记清除 

将垃圾回收分为两个阶段:标记阶段和清除阶段。

标记阶段:首先通过根节点,标记所有从根结点开始的可达对象,未被标记的就是为被引用的垃圾对象。

清除阶段:清除所有未被标记的对象。把死亡对象占据内存标记为空闲内存,记录在空闲列表中,需要新建对象时,内存管理模块会从空闲列表中寻找空闲内存,划分给新建的对象

问题

1.内存碎片。Java虚拟机的堆中对象必须是连续分布的,因此可能会出现总空闲内存足够,但是无法分配的情况。

2.分配效率低。如果是连续的内存空间,可以通过指针加法来分配,对于空闲列表则需要逐个访问列表中的项,来查找能够放入新对象的空闲内存。

3.标记压缩 - 老年代回收算法

在清除算法的基础上做了一些优化。(标记清除+内存压缩

标记阶段:从根结点开始对所有可达对象做一次标记。

压缩阶段:将所有存活对象压缩到内存的一端,之后,清理边界外所有的空间。

为什么适合老年代?

老年代对象大部分都是存活的对象,如果使用复制算法,由于存活对象较多,复制的成本也将更高。

4.复制 - 新生代回收

核心思想:将原有内存空间分为两块,每次只使用其中一块(from指向的那块),在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中(to),之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

优点:

1.如果系统中垃圾对象很多,复制算法需要复制的存活对象数量就会相对较少。因此复制算法的效率是很高的。

2.对象是统一被复制到新的内存空间,因此,可确保回收后的内存空间是没有碎片的。

缺点:

将内存空间折半,内存利用率不高。

 在Java新生代串行垃圾回收器中,使用了复制算法的思想。

新生代分为Eden、from和to(2个Survivor)

发生 Minor GC Eden from 指向的 Survivor 中的存活对象会被复制到 to 指向的 Survivor 中,然后交换 from to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的

5.分代算法

思想:将内存区间根据对象特点分成几块,使用不同的回收算法,提高垃圾回收的效率。

一般将堆分为新生代和老年代。

新生代:复制算法

老年代:标记清除、标记压缩。

卡表

卡表是一个比特位的集合,每个比特位用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用。这样在Minor GC时,可以不用花时间扫描所有老年代对象,可以先扫描卡表,只有当卡表位为1时,才需要扫描给定区域的老年代对象。使用这种方式,可以加快Minor GC的回收速度

6.分区算法

分代算法将对象按生命周期长短分为两个部分。分区算法将整个堆空间划分成连续的不同小区间。每个小区间独立使用,独立回收,这种算法的好处是可以控制一次回收多少个小区间。

6. GC用的引用可达性分析算法中,哪些对象可作为GC Roots对象?

虚拟机栈(栈帧中的本地变量表)中引用的对象。

方法区中静态属性引用的对象。

方法区中常量引用的对象。

本地方法栈中 JNI (即一般说的 Native 方法)引用的对象

7.引用和可触及性的强度

Java中4个级别的引用:强引用、软引用、弱引用、虚引用。

强引用 - 不回收的引用

一般使用的引用类型,强引用的对象是可触及的,不会被回收。软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下都是可以被回收的。

特点:

  • 强引用可以直接访问目标对象
  • 强引用指向的对象在任何时候都不会被系统回收,宁愿OOM也不会回收强引用对象。
  • 可能导致内存泄漏。
StringBuffer str = new StringBuffer("Hello");

假设这个代码是在方法体内运行的,那么局部变量str将被分配在栈上,而StringBuffer对象被分配在堆上,str指向StrungBuffer对象所在的堆空间,str是StringBuffer实例的强引用。

再运行赋值语句

StringBuffer str1 = str;

软引用-可被回收的引用

软引用是比强引用弱一点的引用类型。一个对象只持有软引用,当堆空间不足时,就会被回收,使用java.lang.ref.SoftReference类实现。

GC未必会回收软引用的对象,但当内存资源紧张时,软引用对象会被回收。所以软引用对象不会引起内存溢出。

补充:内存泄漏和内存溢出的区别

内存溢出:OOM。系统已经不能再分配出你所需要的空间,比如你需要100M的空间,系统只剩90M了,这就叫内存溢出。

内存泄漏:强引用所指向的对象不会被回收,可能导致内存泄漏。泄露多了就溢出了

 弱引用-发现就回收

弱引用比软引用还弱,在GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。但由于垃圾回收器的线程通常优先级很低,因此并不一定能很快发现持有弱引用的对象,这种情况下弱引用对象可以存活较长时间

一旦一个弱引用对象被回收,便会加入到一个注册的引用队列中。

补充:软引用和弱引用适合什么样的场景?

适合保存可有可无的缓存数据,当系统内存不足时会被回收,不会导致内存溢出。当内存充足时,可以存在很长时间,起到加速系统的作用。

虚引用-对象回收跟踪

 最弱。一个持有虚引用的对象,随时都可能被回收。试图通过虚引用的get方法获得强引用时,总是会失败,虚引用必须和引用队列一起使用,作用是跟踪垃圾回收过程。

8.Stop-The-World 

为了让垃圾回收器可以正常且高效的执行,大部分情况下会要求系统进入一个停顿的状态,停顿的目的是为了终止所有应用线程的执行,不会产生新的垃圾,保证了系统状态在某一个瞬间的一致性,有利于垃圾收集器更好地标记垃圾对象。

9.垃圾收集器

1.串行回收器

用单线程进行垃圾回收。分为新生代串行回收器老年代串行回收器

特点:

单线程垃圾回收。

独占式垃圾回收。

实时性高的场景中不可以接受。

1.1新生代串行回收器

成熟,使用复制算法,实现相对简单,没有线程切换的开销。在单CPU等硬件平台不是特别优越的场合,性能要好于并行和并发回收器。

1.2老年代串行回收器

标记压缩算法。一旦启动可能会停顿较长时间。可以和多种新生代回收器配合使用,也可以作为CMS回收器的备用回收器

 2.并行回收器

使用多个线程进行垃圾回收。

2.1新生代ParNew回收器

简单讲将串行回收器多线程化,回收策略、算法和参数都和新生代串行回收器一样。

2.2新生代ParallerGC回收器

复制算法重要特点是非常关注系统的吞吐量。并且支持自适应GC调节策略。(和ParNew的区别)

2.3老年代ParallelOldGc回收器

标记压缩算法,和ParallerGC搭配使用。

3.CMS回收器 (Concurrent Mark Sweep 并发标记清除)

CMS主要关注系统停顿时间。使用标记清除算法多线程并行回收

工作的主要步骤

初始标记独占): 标记GC Roots能直接关联到的对象,速度很快

并发标记: 遍历初始标记阶段标记出来的存活对象,然后继续递归标记这些对象可达的对象

预清理:为正式清理做准备和检查,尝试控制一次停顿时间。

(由于重新标记独占CPU,如果新生代GC发生后立即触发重新标记,那这一次停顿时间可能很长,为了避免这种情况,预处理时会可以等待一次新生代GC,然后根据历史性能数据预测下一次新生代GC可能发生的时间,然后在当前时间和预测的中间时刻进行重新标记,这样从最大程度上避免新生代GC和重新标记重合,尽可能减少一次停顿时间。 -----我没理解这段话)

重新标记(独占):修正由于并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录

并发清理:与用户线程一起运行,清理那些无效的对象

并发重置:回收完成后,重新初始化CMS数据结构和数据,为下次GC做好准备。

CMS基于标记清除,那么会产生碎片,-XX:+UseCMSCompactAtFullCollection可以在CMS收集完成后进行一次内存碎片整理。

4.G1回收器

JDK1.7使用的垃圾回收器,为了取代CMS回收器。

G1有独有的垃圾回收策略

G1属于分代回收,区分新生代和老年代,有Eden和Survivor区。从堆结构上看并不要求新生代和老年代都连续,它使用了分区算法

特点:

  • 并行性:G1回收期间,可以由多个GC线程同时工作
  • 并发性:G1拥有与应用程序交替执行的能力,一般来说不会在整个回收期间完全阻塞应用程序。
  • 分代GC:同时兼顾新生代和老年代。其他回收器都只在一个代工作
  • 空间整理:回收过程中会进行适当的对象移动,CMS只是简单标记清除,若干次GC后CMS必须进行一次碎片整理,而G1,每次回收都会有效复制对象,减少碎片。
  • 可预见性:由于分区可以只选取部分区域进行回收,缩小了回收范围,对于全局停顿能得到较好的控制。

收集过程:

  • 新生代GC
  • 并发标记周期
  • 混合收集
  • 如果需要可能会进行Full GC

新生代GC

主要工作:回收Eden和Survivor区。一旦Eden占满,新生代GC会启动。

Eden区被清空,Survivor区被收集一部分,至少应该存在一个Survivor区,Old区增多。

CMS和G1的区别:

1.CMS作用在老年代,而G1同时兼顾新生代和老年代。

2.G1是分区的。

3.对于跨域的引用(老年代有新生代的引用):CMS使用和上面讲的卡表基本一样,就是维护一个卡表,每次新生代GC时扫描卡表即可,G1在卡表的基础上引入了Remembered Set,每个region都会维护一个RSet,记录着引用到本region中的对象的其他region的卡表。比如A对象在regionA,B对象在regionB,且B.f = A,则在regionA的RSet中需要记录B所在的Card的地址。这样的好处是可以对region进行单独回收,这要求RSet不只是维护老年代到年轻代的引用,也要维护这老年代到老年代的引用,对于跨代引用的每次只要扫描这个region的RSet上的Card即可。

4.Full GC

导致CMS Full GC的可能原因主要有两个:Promotion FailureConcurrent Mode Failure

前者是在年轻代晋升的时候老年代没有足够的连续空间容纳,很有可能是内存碎片导致的;后者是在并发过程中jvm觉得在并发过程结束前堆就会满了,需要提前触发Full GC。CMS的Full GC是一个多线程STW的Mark-Compact过程,需要尽量避免或者降低频率。
G1的初衷就是要避免Full GC的出现,Full GC会对所有region做Evacuation-Compact,而且是单线程的STW,非常耗时间。导致G1 Full GC的原因可能有两个:1. Evacuation的时候没有足够的to-space来存放晋升的对象2. 并发处理过程完成之前空间耗尽。这两个原因跟CMS类似。

5.Write Barrier

Write Barrier可以理解为在写的时候插入一条特定的操作
在CMS中老年代引用年轻代的时候就是通过触发一个Write Barrier来更新Card Table的标志位。这是一个同步操作,在更新引用的时候顺带执行,只需要两个指令,引入的消耗不大。
G1比较复杂,在两个地方用到了Write Barrier,分别是更新RSet的rememberd set Write Barrier记录引用变化的Concurrent Marking Write Barrier,前者发生在引用更新之后,称为Post Write Barrier,后者发生在引用变化之前,称为Pre Write Barrier。G1为了提高性能,这两个Write Barrier都是先放到队列中,再异步进行处理。

10.Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

1. 调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

2. 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间

3. 空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。

4. JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5. Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

11.内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的 。

(因为不知道新生代有多少对象会被复制到老年代,最坏情况也是所有对象都存活,并被复制到老年代,于是要比较新生代所有对象总空间)

如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC.

12.TLAB 线程本地分配缓存

线程专用的内存分配区域,为了加速对象分配而生。

由于对象一般在堆上分配,而堆是共享的,因此在同一时刻,可能有多个线程都在堆上申请空间,因此每一次分配都需要进行同步,而竞争激烈的场景下分配的效率又会进一步下降,因为对象分配几乎是java最常用的操作,因此JVM使用TLAB这种线程专属的区间来避免多线程冲突,提高对象分配效率。

TLAB占用Eden区空间,在TLAB启用的情况下,虚拟机会为每个Java线程分配一块TLAB空间。

TLAB空间一般不会太大,因此大对象无法在TLAB进行分配,会直接分配在堆上。如果有200KB,已经分配了150KB,来了一个60KB的对象,就无法分配了,这时候虚拟机会怎么处理呢?虚拟机内部维护一个refill_waste的值,当请求对象大于refill_waste就在堆重分配,小于该值就废弃当前TLAB,新建TLAB来分配新对象。他表示TLAB中允许产生浪费的比例,默认64,即使用1/64的TLAB空间大小作为refill_waste。

13.String常量池的位置

JDK1.6之前,字符串常量池属于永久区的一部分。

JDK1.7之后,被移到了堆中。

14.锁在JVM中的实现和优化

1.偏向锁

JDK1.6提出。

核心思想

如果程序没有竞争,则取消之前已经取得锁的线程同步操作。

也就是说,某个锁被线程获取之后,便进入偏向模式,当线程再次请求这个锁时,无需再进行相关的同步操作,从而节省操作时间。

如果在此之间有其他线程进行锁请求,则锁退出偏向模式。

特点

偏向锁在竞争少的情况下,对系统性能有一定帮助。

但在锁竞争激烈的场合没有太强的优化效果,大量竞争会不断导致持有锁的线程不停切换,锁也很难一直保持在偏向模式。此时反而有可能降低系统性能。

2.轻量级锁

偏向锁失败,JVM会让线程申请轻量级锁。

在JVM内部,使用BasicObjectLock的对象实现,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成。

主要是CAS操作,将BasicLock的地址复制到对象头的Mark Word(对象hash值,对象年龄,锁的指针等信息)。复制成功则加锁成功,否则认为加锁失败,轻量级锁就可能膨胀为重量级锁。

3.锁膨胀

当轻量级锁失败,JVM就会使用重量级锁。

4.自旋锁

自旋锁可以使线程在没有取得锁时不被挂起,而去执行一个空循环(自旋),若干个空循环后,如果可以获得锁则继续执行,不能获得则被挂起。

优点:

线程被挂起几率降低,线程执行的连贯性加强。

缺点:

对于竞争激烈,单线程锁占用时间长的并发程序,自旋锁在自旋等待后,往往依然无法获得对应的锁。白白浪费CPU的时间,最后还是被挂起。

5.锁消除

JVM在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。

如果不存在竞争,为什么要加锁?

开发时可能会使用一些API,比如StringBuffer、Vector等,可能在完全没有多线程的情况下被使用,因此JVM通过逃逸分析技术,捕获这些不可能存在竞争却有申请锁的代码,消除这些不必要的锁,从而提高系统性能。

15.锁在应用层的优化思路

1.减少锁持有时间。

只在有必要时进行同步。

public synchronized void method(){
        method1();
        mutextMethod();//只有这个方法需要同步
        method2();
    }

只有mutextMethod()需要同步,却给整个method()加锁,如果method1和method2都是重量级的,则会花费很长的CPU时间。

可以这样解决:

public void method(){
        method1();
        synchronized (this){ //只在有必要时加锁
            mutextMethod();//只有这个方法需要同步
        }
        method2();
    }

2.减小锁粒度

缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统并发能力。

1.7中的ConcurrentHashMap的Segment属于减小锁力度。(1.8不用Segment了)

插一条:为啥1.8不用Segment了?

1.8使用synchronized+CAS ,1.7使用Segment+ReentrantLock

使用Segment+ReentrantLock的好处:

首先,当我们使用put方法的时候,是对我们的key进行hash拿到一个整型,然后将整型对16取模,拿到对应的Segment,之后调用Segment的put方法,然后上锁,请注意,这里lock()的时候其实是this.lock(),也就是说,每个Segment的锁是分开的

其中一个上锁不会影响另一个,此时也就代表了我可以有十六个线程进来,ReentrantLock上锁的时候如果只有一个线程进来,是不会有线程挂起的操作的,也就是说只需要在AQS里使用CAS改变一个state的值为1,此时就能对代码进行操作,这样一来,我们等于将并发量/16了。

为什么使用synchronized+CAS呢?

putVal的源码得到的结论:

Synchronized是靠对象的对象头和此对象对应的monitor来保证上锁的,也就是对象头里的重量级锁标志指向了monitor,而monitor呢,内部则保存了一个当前线程,也就是抢到了锁的线程.

那么这里的这个f是什么呢?它是Node链表里的每一个Node,也就是说,Synchronized是将每一个Node对象作为了一个锁,这样做的好处是什么呢?将锁细化了,也就是说,除非两个线程同时操作一个Node,注意,是一个Node而不是一个Node链表哦,那么才会争抢同一把锁.

如果使用ReentrantLock其实也可以将锁细化成这样的,只要让Node类继承ReentrantLock就行了,这样的话调用f.lock()就能做到和Synchronized(f)同样的效果,但为什么不这样做呢?

请大家试想一下,锁已经被细化到这种程度了,那么出现并发争抢的可能性还高吗?还有就是,哪怕出现争抢了,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销.

但如果是ReentrantLock呢?它则只有在线程没有抢到锁,然后新建Node节点后再尝试一次而已,不会自旋,而是直接被挂起,这样一来,我们就很容易会多出线程上下文开销的代价.当然,你也可以使用tryLock(),但是这样又出现了一个问题,你怎么知道tryLock的时间呢?在时间范围里还好,假如超过了呢?

所以,在锁被细化到如此程度上,使用Synchronized是最好的选择了.这里再补充一句,Synchronized和ReentrantLock他们的开销差距是在释放锁时唤醒线程的数量,Synchronized是唤醒锁池里所有的线程+刚好来访问的线程,而ReentrantLock则是当前线程后进来的第一个线程+刚好来访问的线程.

如果是线程并发量不大的情况下,那么Synchronized因为自旋锁,偏向锁,轻量级锁的原因,不用将等待线程挂起,偏向锁甚至不用自旋,所以在这种情况下要比ReentrantLock高效。

来自:https://www.cnblogs.com/yangfeiORfeiyang/p/9694383.html

3.锁分离

这是减小锁粒度的一个特例,根据程序功能特点,将一个独占锁分成多个锁,典型就是java.util.concurrent.LinkedBlockingQueue的实现。

LinkedBlockQueue支持take()和put()分别从队列中取数据和加数据,虽然都是操作当前队列,但在链表中这俩操作是分别在表头和表尾的,理论上不冲突,因此如果整个list加锁,take和put就不能并发,从而影响性能。

JDK中使用两个不同的锁,分离了take和put操作。

    /** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();

4.锁粗化

虚拟机在遇到一连串对同一锁不断请求和释放操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数。

//每个循环都对一个对象申请锁
for(int i = 0; i < n; i++){
   synchronized(lock){
   
   }
}

//粗化
synchronized(lock){
  for(int i = 0; i < n; i++){
  
  }
} 

16.无锁

CAS,Compare And Swap.

包含三个参数CAS(V,E,N)

V:要更新的变量

E:预期值

N:新值

仅当V=E时,才会把V更新成N。否则循环的尝试。

在J.U.C下的atomic包下,有一组使用无锁算法实现的原子操作类。

AtomicInteger.....

底层实际上就是调用unsafe提供的CAS操作。(实在是不想写了= =)

为什么有LongAdder?

它也是减小锁粒度的一种思路,对AtomicXXX进行改进。

将热点数据进行分离,将AtomicInteger内部核心数据分离成一个数组,每个线程访问时,通过hash算法映射到其中一个数字进行计数,最终的结果,则是这个数组的求和累加。

缺点:

统计的时候如果有并发更新可能导致误差。

在处理高并发时可以有限使用LongAdder,在并发低时还是使用AtomicLong。

而且需要准确的数据也不适合用LongAdder。

17.类初始化时机

虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随之发生):

1.遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。

2.使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

3.当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化

4.当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

18.类加载机制

加载、链接、初始化

链接又分为:验证,准备、解析阶段。

加载,是指查找字节流,并且据此创建类的过程

链接:是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。

    验证:就是要确保被加载的类能满足虚拟机的约束条件;

    准备:为被加载类的静态字段分配内存。

    解析:将这些符号引用解析成为实际引用

(解析,在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具      体地 址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)

初始化: 便是为标记为常量值的字段赋值,以及执行 <clinit> 方法的过程

类静态成员的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 <clinit>。

19.类加载器

在加载的时候需要根据全限定名查找字节流,这就是类加载器要做的事。

两个类相等:只有被同一个类加载器加载的类才可能会相等相同的字节码被不同的类加载器加载的类不相等

20.类加载器分类:

启动类加载器:c++实现的,负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)

扩展类加载器 :父类是启动类加载器,加载 <JAVA_HOME>/lib/ext 目录下的类库

应用程序类加载器 :父类是扩展类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

当系统需要使用一个类,在判断类是否被加载时,会自底向上的检查

当系统需要加载一个类会从顶层开始加载(双亲委派)

21.双亲委派模型

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的加载器都是如此,因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法完成该加载请求(该加载器的搜索范围中没有找到对应的类)时,子加载器才会尝试自己去加载。

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。

22.有没有可能父类和子类加载一个类?

父类加载器和子类加载器可以加载同一个类,但是加载完就不是同一个类了,因为类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。

23.突破双亲模式

通过重载ClassLoader可以修改该行为。Tomcat就有自己的类加载顺序。

Tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委托。 

既然 Tomcat 不遵循双亲委派机制,那么如果我自己定义一个恶意的HashMap,会不会有风险呢?(阿里的面试官问)

答: 显然不会有风险,如果有,Tomcat都运行这么多年了,那群Tomcat大神能不改进吗? tomcat不遵循双亲委派机制,只是自定义的classLoader顺序不同,但顶层还是相同的,还是要去顶层请求classloader.

Tomcat是个web容器, 那么它要解决什么问题: 


1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。 
2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是不可取的。 
3. web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。 
4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启

Tomcat 如果使用默认的类加载机制行不行? 

答案是不行的。为什么?

第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加载器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。

第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性

第三个问题和第一个问题一样。

我们再看第四个问题,我们想我们要怎么实现jsp文件的热修改(楼主起的名字)jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件

Tomcat 如何实现自己独特的类加载机制?

我们看到,前面3个类加载和默认的一致。

CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/*/server/*/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器

  • commonLoaderTomcat最基本的类加载器,加载路径中的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 为了实现隔离性,没有遵守双亲委派这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。

如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?
(父类加载器无法访问底层的加载器所加载的类,怎么办?)

看了前面的关于破坏双亲委派模型的内容,我们心里有数了,我们可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作

来自:https://www.cnblogs.com/aspirant/p/8991830.html

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值