Java的JVM和并发学习

JVM内存结构

对于JVM内存结构这块,原内容是基于1.7版本的,现在基于1.8版本做了大幅度改动,放到了Java实习生面试复习(十二):JVM内存结构/运行时数据区中做了一个总结。


垃圾回收器与内存分配策略

概述:

程序计数器、虚拟机栈、本地方法栈3个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。

在内存回收之前要做的事情就是判断哪些对象是死的,哪些是活的。

策略1:引用计数法

给对象添加引用计数器,但是存在循环引用问题

Java的JVM和并发学习_第1张图片

从上图中可以看出,就算我们将Obj1和Obj2置为null,但在Java堆中的两块内存依然存在互相引用,所以无法回收。

策略2:可达性分析法

通过一系列GC Roots的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连的时候说明对象不可用。

Java的JVM和并发学习_第2张图片
GC Roots 包括

  • Java 线程中,当前所有正在被调用的方法的引用类型参数、局部变量、临时值等。也就是与我们栈帧相关的各种引用。
  • 所有当前被加载的 Java 类。
  • Java 类的引用类型静态变量。
  • 运行时常量池里的引用类型常量(String 或 Class 类型)。
  • JVM 内部数据结构的一些引用,比如 sun.jvm.hotspot.memory.Universe 类。
  • 用于同步的监控对象,比如调用了对象的 wait() 方法。
  • JNI handles,包括 global handles 和 local handles。
    这些 GC Roots 大体可以分为三大类,下面这种说法更加好记一些:
  • 活动线程相关的各种引用。
  • 类的静态变量的引用。
  • JNI 引用。

在JDK1.2之后,引用概念进行了扩充,大概有四种:

  • 强引用,类似于 Object obj = new Object(); 创建的,只要强引用在就不回收。
  • 软引用,SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。
  • 弱引用,WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
  • 虚引用,PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列(ReferenceQueue)联合使用。

回收方法区(元空间)

在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而永久代的垃圾收集效率远低于此。

永久代垃圾回收主要两部分内容:废弃的常量和无用的类。

判断废弃常量:一般是判断没有该常量的引用。

判断无用的类:要以下三个条件都满足

  • 该类所有的实例都已经回收,也就是 Java 堆中不存在该类的任何实例
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有任何地方呗引用,无法在任何地方通过反射访问该类的方法

垃圾回收算法

算法1:标记-清除算法,直接标记清除

两个不足:效率不高、空间会产生大量碎片

算法2:复制算法

为了解决前一种方法的不足,但是会造成空间利用率低下。因为大多数新生代对象都不会熬过第一次GC。所以可以分一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中一块Survivor。当回收时,将Eden和Survivor中还活着的对象一次性复制到另一块Survivor上,最后在清理Eden和Survivor空间,大小比例一般是8:1:1,每次浪费10%的Survivor空间,但是这里存在一个问题就是存活大于10%的怎么办,这里采用一种分配担保策略,多出来的对象直接进入老年代。

复制算法特别适合用于存活对象少,垃 圾对象多的情况 比如新生代

算法3:标记-整理算法

不同于针对新生代的复制算法,针对老年代的特点,创建该算法。主要是把存活对象移到内存的一端。

算法4:分代回收

根据存活对象划分几块内存区,一般是分为新生代和老年代。然后根据各个年代的特点制定相应的回收算法。

GC种类:YoungGC、OldGC、FullGC

一般把Java分为新生代和老年代:

  • 新生代存活对象少,可回收对象多,选用复制算法。
  • 老年代,对象存活率高,回收对象少,选用标记-整理算法或清理算法。

注意:这里从Survivor区也就是幸存区到老年代,对于对象的年龄要求为15次,为什么15,因为4bit的最大只能表示16次组合状态,即2^4


类加载机制

1.类的生命周期

Java的JVM和并发学习_第3张图片

如上图所示,描述了类的生命周期。其中加载、验证、准备、初始化、卸载这五个动作是存在先后顺序的,而解析阶段有可能在初始化之后完成的。

这里着重说一下准备阶段【重点】

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

这里需要注意两个关键点,即内存分配的对象以及初始化的类型。

内存分配的对象:要明白首先要知道Java 中的变量有类变量以及类成员变量两种类型,类变量指的是被 static 修饰的变量,而其他所有类型的变量都属于类成员变量。在准备阶段,JVM 只会为类变量分配内存,而不会为类成员变量分配内存。类成员变量的内存分配需要等到初始化阶段才开始。

举个例子:例如下面的代码在准备阶段,只会为 A属性分配内存,而不会为 C属性分配内存。

public static int A = 666;
public static final int B = 666;
public String C = "jvm";

初始化的类型:在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的默认值,而不是用户代码里初始化的值。 但如果一个变量是常量(被 static final 修饰)的话,那么在准备阶段,属性便会被赋予用户希望的值。例如B在准备阶段之后,B的值将是 666,而不再会是 0。

之所以 static final 会直接被复制,而 static 变量会被赋予java语言类型的默认值。其实我们稍微思考一下就能想明白了:

A和B两个的区别是一个有 final 关键字修饰,另外一个没有。而 final 关键字在 Java 中代表不可改变的意思,意思就是说 B的值一旦赋值就不会在改变了。既然一旦赋值就不会再改变,那么就必须一开始就给其赋予用户想要的值,因此被 final 修饰的类变量在准备阶段就会被赋予想要的值。而没有被 final 修饰的类变量,其可能在初始化阶段或者运行阶段发生变化,所以就没有必要在准备阶段对它赋予用户想要的值。

2.双亲委派模型

介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的类加载器和这个类的全限定名一同确立在jvm中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将class文件加载到JVM内存,转为Class对象。 从jvm角度来看只存在两种类加载器

  • 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载JAVA_HOME/lib/目录中的,或者被-Xbootclasspath参数所指定的路径中并且被虚拟机识别的类库。
  • 其他类加载器:由Java语言实现,继承自抽象类ClassLoader:
    1. 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库。
    2. 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

Java的JVM和并发学习_第4张图片

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。下面举一个大家都知道的例子说明为什么要使用双亲委派模型。

黑客自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍作修改。比如equals函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到JVM中。此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致“病毒代码”被执行。

而有了双亲委派模型,黑客自定义的java.lang.String类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。

有的时候我们也需要打破双亲委派模型,比如Tomcat底层就是打破了双亲委派模型,还有SPI机制,利用当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动
Java的JVM和并发学习_第5张图片


进程和线程、并发和并行

  1. 进程是资源(cpu、内存等)分配的基本单位,它是程序执行时的一个实例

  2. 线程是程序执行时的最小单位,一个进程可以有多个线程,线程间共享进程的所有资源,每个线程还有自己的堆栈和局部变量。

  3. 并发是指一个时间段内,有几个程序都在同一个CPU上运行,但任意一个时刻点上只有一个程序在处理机上运行。

  4. 并行是指一个时间段内,有几个程序都在几个CPU上运行,任意一个时刻点上,有多个程序在同时运行,并且多道程序之间互不干扰。

Java的三个包JUC(java.util.concurrent)、java.util.concurrent.atomic、java.util.concurrent.locks


怎么理解阻塞非阻塞与同步异步的区别?

同步异步它们关注的是消息通信机制,所谓同步就是在发出一个调用时,在没有得到结果前该调用就不返回。异步则相反,调用发出后,这个调用就直接返回了,也就是说调用者不会立即得到结果,而是事后被调用者通过状态或通知、回调函数来告诉通知者。例如:你打电话问书店老板有没有《分布式系统》这本书。

同步阻塞:你打电话问老板有没有某书,老板去查,在老板给你结果之前,你一直拿着电话等待老板给你结果,你此时什么也干不了。

同步非阻塞:你打电话过去后,在老板给你结果之前,你拿着电话等待老板给你结果,但是你拿着电话等的时候可以干一些其他事,比如嗑瓜子。

异步阻塞:你打电话过去后,老板去查,你挂掉电话,等待老板给你打电话通知你,这是异步,你挂了电话后还是啥也干不了,只能一直等着老板给你打电话告诉你结果,这是阻塞。

异步非阻塞:你打电话过去后,你就挂了电话,然后你就想干嘛干嘛去。只用时不时去看看老板给你打电话没。


synchronized and lock

理解Java对象头和Monitor

在jvm中,对象在内存中的布局分为三块区域:对象头、实例数据、对齐填充(数据对齐)

其中对象头一定存在,实例数据和对齐填充不一定存在,例如一个类L,没有属性,那么它就没有实例数据,如果有属性,且整合满足8的倍数,那么就不存在对齐填充。

在现今64位的jvm虚拟机上对象头为 96bit = 12byte

Java对象头:96bit

Mark Word:64bit 8byte

klass pointer: 32bit 4byte / 64bit 8byte

因为有的虚拟机默认开启了指针压缩,所以是32bit 4byte
Java的JVM和并发学习_第6张图片

jdk1.6以后Synchronized的优化:锁膨胀机制:无锁 => 偏向锁 => 轻量级锁 => 重量级锁

偏向锁:测试的时候记得加上-XX:BiasedLockingStartupDelay=0,因为默认情况下存在延迟开启,当线程第一次访问同步块并获取锁时, 偏向锁处理流程如下:

  • 虚拟机将会把对象头中的标志位设为"01",即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

偏向锁的撤销:

  • 偏向锁的撤销动作必须等待全局安全点
  • 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
  • 撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态

锁消除是指虚拟机即时编译器UIT) 在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争
的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中, 堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。变鼂是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在
Java程序中的普遍程度也许超过了大部分读者的想象。下面这段非常简单的代码仅仅 是输出3个字符串相加的结
果,无论是源码字面上还是程序语义上都没有同步。

两者的区别如下:

ReentrantLock是juc中一个非常有用的组件,很多并发集合类是用它实现的,例如ConcurrentHashMap。它具有是哪个特性:等待可中断,可实现公平锁,以及锁可以绑定多个条件。

ReentrantLock和synchronized关键字一样,属于互斥锁,但synchronized的锁是非公平的。

公平锁指多个线程等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。

ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造方法使用公平锁,用lock获得锁,unlock释放锁,但它需要将方法置于try-finally块中,以免忘记释放锁。

性能上,在1.6以前,ReentrantLock明显优于synchronized,但1.6以后加入了很多针对锁的优化,所以两者性能基本持平。

在使用lock的时候,可以抛弃Object.wait和notify的写法,通过lock的newCondition使用Condition接口。

Condition的功能类似于传统线程技术中的Object.wait()和Object.notify()方法的功能,但它是将这些方法分解成不同的对象,所以可以将这些对象与任意的Lock实现组合使用,实现在不同的条件下阻塞或唤醒线程;也就是说,这其中的Lock替代了synchronized方法和语句的使用,Condition替代了Object 监视器方法(wait、notify 和 notifyAll)的使用。

interrupt(),interrupted() 和 isInterrupted() 的区别

interrupt():将调用该方法的对象所表示的线程标记一个停止标记,并不是真的停止该线程。

interrupted():获取当前线程的中断状态,并且会清除线程的状态标记。是一个是静态方法。

isInterrupted():获取调用该方法的对象所表示的线程,不会清除线程的状态标记。是一个实例方法。

synchronized的基本规则如下:

Java的JVM和并发学习_第7张图片

我们将synchronized的基本规则总结为下面3条
第一条:当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对“该对象”的该“synchronized方法”或者“synchronized代码块”的访问将被阻塞。
第二条:当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程仍然可以访问“该对象”的非同步代码块。
第三条:当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对“该对象”的其他的“synchronized方法”或者“synchronized代码块”的访问将被阻塞。
注意:静态同步方法锁的是类,普通同步方法锁的是对象,两者之间不冲突,没有竞态条件


常见的锁机制

重量级锁

我们知道,我们要进入一个同步、线程安全的方法时,是需要先获得这个方法的锁的,退出这个方法时,则会释放锁。如果获取不到这个锁的话,意味着有别的线程在执行这个方法,这时我们就会马上进入阻塞的状态,等待那个持有锁的线程释放锁,然后再把我们从阻塞的状态唤醒,我们再去获取这个方法的锁。

这种获取不到锁就马上进入阻塞状态的锁,我们称之为重量级锁

自旋锁(还有适应性自旋锁)

我们知道,线程从运行态进入阻塞态这个过程,是非常耗时的,因为不仅需要保存线程此时的执行状态,上下文等数据,还涉及到用户态内核态的转换。当然,把线程从阻塞态唤醒也是一样,也是非常消耗时间的。

刚才我说线程拿不到锁,就会马上进入阻塞状态,然而现实是,它虽然这一刻拿不到锁,可能在下 0.0001 秒,就有其他线程把这个锁释放了。如果它慢0.0001秒来拿这个锁的话,可能就可以顺利拿到了,不需要经历阻塞/唤醒这个花时间的过程了。

然而重量级锁就是这么坑,它就是不肯等待一下,一拿不到就是要马上进入阻塞状态。为了解决这个问题,我们引入了另外一种愿意等待一段时间的锁 — 自旋锁

自旋锁就是,如果此时拿不到锁,它不马上进入阻塞状态,而是等待一段时间,看看这段时间有没其他人把这锁给释放了。怎么等呢?这个就类似于线程在那里做空循环,如果循环一定的次数还拿不到锁,那么它才会进入阻塞的状态。

轻量级锁

上面我们介绍的三种锁:重量级、自旋锁和自适应自旋锁,他们都有一个特点,就是进入一个方法的时候,就会加上锁,退出一个方法的时候,也就释放对应的锁。

之所以要加锁,是因为他们害怕自己在这个方法执行的时候,被别人偷偷进来了,所以只能加锁,防止其他线程进来。这就相当于,每次离开自己的房间,都要锁上门,人回来了再把锁解开。

这实在是太麻烦了,如果根本就没有线程来和他们竞争锁,那他们不是白白上锁了?要知道,加锁这个过程是需要操作系统这个大佬来帮忙的,是很消耗时间的,。为了解决这种动不动就加锁带来的开销,轻量级锁出现了。

轻量级锁认为,当你在方法里面执行的时候,其实是很少刚好有人也来执行这个方法的,所以,当我们进入一个方法的时候根本就不用加锁,我们只需要做一个标记就可以了,也就是说,我们可以用一个变量来记录此时该方法是否有人在执行。也就是说,如果这个方法没人在执行,当我们进入这个方法的时候,采用CAS机制,把这个方法的状态标记为已经有人在执行,退出这个方法时,在把这个状态改为了没有人在执行了。

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程
在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

悲观锁和乐观锁

最开始我们说的三种锁,重量级锁、自旋锁和自适应自旋锁,进入方法之前,就一定要先加一个锁,这种我们为称之为悲观锁。悲观锁总认为,如果不事先加锁的话,就会出事,这种想法确实悲观了点,这估计就是悲观锁的来源了。

而乐观锁却相反,认为不加锁也没事,我们可以先不加锁,如果出现了冲突,我们在想办法解决,例如 CAS 机制,上面说的轻量级锁,就是乐观锁的。不会马上加锁,而是等待真的出现了冲突,在想办法解决。

volatile知识点

对于volatile知识点这块,因为涉及到的内容比较多,篇幅太长了不好,放到了Java实习生面试复习(八):volatile的学习中做了一个总结,想看的读者自行移步哈。

你可能感兴趣的:(Java语言基础,java,jvm,多线程,面试)