Java的JVM和并发学习

1.1 JVM内存结构

java虚拟机在执行程序的过程中会将内存划分为不同的数据区域
img

线程独享区可以中包含以下三种数据区域:

  • 程序计数器(Program Counter Register)
  • 虚拟机栈(VM Stack)
  • 本地方法栈(Native Method Stack)

线程共享区中包含以下两种数据区域:

  • 方法区(Method Area)
  • 堆(Heap)

在JVM外部也就是本地内存中,包含了直接内存元数据(Metadata),在JDK 1.8中,元数据就是我们之前的永久代(持久代)

他们之间的区别是,JDK 7在持久代中的常量池在JDK 8移到了堆内存中,剩余部分移到了元数据中。

JVM不同区域的占用内存大小不同,一般情况下堆最大,程序计数器较小。那么最大的区域会放什么?当然就是Java中最多的“对象”了。

Java常量池有哪些:Class文件常量池运行时常量池,当然还有全局字符串常量池,以及基本类型包装类对象常量池


1.2 多线程的实现原理

上面我们把运行时的数据区域分为了线程独占区和线程共享区,那么Java中的多线程是怎么实现的呢?这可以帮助我们对于线程独占区和共享区有更深的理解。

在多个线程运行的时候,其实是把CPU的使用时间分割成了无数个小份,然后根据优先级去给这些线程分配时间,CPU在这些小时间块中快速切换,给用户的感受就是多线程同时在运行,如下图:

img

通过这个图,我们可以清晰的看出是如何达到多线程的效果(其实在通信的时候也有同样的原理——时分多路复用)

扫描二维码关注公众号,回复: 8960887 查看本文章

其实通俗一点来说,线程的独占区主要是为了控制方法的正常运行,而线程的共享区更类似于一个存储信息的仓库

打个简单的比方,现在有一个方法,我们使用两个线程同时去调用这个方法,属于该方法的信息就可以称之为独占区,而类中的变量,对象却可以被两个方法同时使用


1.3 堆(Heap)

堆内存最大,堆是被线程共享,堆的目的就是存放对象。几乎所有的对象实例都在此分配。当然,随着优化技术的更新,某些数据也会被放在栈上。因为堆空间最大,所以也是Java垃圾回收的主要区域,因此也被称为"GC堆"

img

现代收集器基本都采用分代收集算法,什么新生代、老年代、永久代这种。对于上图,我们可以简单看出

  • 堆的GC采用的就是分代收集算法
  • 堆区分了新生代和老年代;
  • 新生代又分为:Eden空间、From Survivor(S0)空间、To Survivor(S1)空间。

从内存分配的角度上来说,堆内存中包含了新生代内存和老年代内存,而年轻代又分为Eden和Survivor区。Survivor区由From Survivor和To Survivor组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1,而且JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。

Java虚拟机规范规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。也就是说堆的内存是一块块拼凑起来的。要增加堆空间时,往上“拼凑”(可扩展性)即可,但当堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

这里提到异常,那我们可能需要了解一下JVM的两种异常

在和JVM打交道的过程中,我们经常会遇到两种错误:StackOverflowErrorOutOfMemoryError

StackOverflowError异常出现在线程独占区的本地方法栈和虚拟机栈中,而OutOfMemoryError会出现在除程序计数器外的所有区域。

  • 栈满抛出StackOverflowError
  • 栈动态扩展到无法申请内存抛出OutOfMemoryError

对象的访问定位

常见的有两种,一种通过句柄访问对象、一种通过直接指针访问对象

  • 通过句柄访问的话,Java堆会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象实例数据和类型数据各自的具体地址信息。使用这种的好处就是在对象被移动的时候,只会改变句柄中的实例数据指针,而reference本身不需要修改。
  • 如果使用直接指针访问,Java堆对象的布局就是必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。使用这种方式的最大好处就是速度更快,减少了一次指针定位的时间开销。

1.4 方法区(元空间)

方法区和堆有很多共性:线程共享、内存不连续、可扩展、可垃圾回收,同样当无法再扩展时会抛出OutOfMemoryError异常。

方法区它存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

img

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。

在JDK 8 之前,方法区被称为(或者可以说是被实现为)持久代,永久代(Perman Gen),而在 JDK 8 之后,取消了永久代的概念,取而代之的实现是元空间(MetaSpace),原本位于永久代中的字符串常量由永久代转移到堆中,而其余的内容则是移到了元空间。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现,它们之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存


1.5 程序计数器

如果我们对计算机组成有所了解,那么我们一定会知道在计算机中有一块儿特殊的区域,称之为寄存器,寄存器包括了指令寄存器和程序计数器,这两样位于CPU中,作为程序运行的大脑来控制程序的运行和流转。

程序计数器的定义

在JVM中,作为一种虚拟机,JVM没有指令寄存器,它是基于栈 + 程序计数器的体系结构来完成方法的执行,一方面的考量就是有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。。程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined

程序计数器的作用

  • Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。

    因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

程序计数器的特点

  • 是一块较小的内存空间。
  • 线程私有,每条线程都有自己的程序计数器。
  • 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
  • 是唯一一个不会出现OutOfMemoryError的内存区域。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined),其实也很好理解,人家压根不属于JVM去管理,你凭什么去记录。。这里我们使用的可以说是Native方法提供出的一个接口,具体的实现是通过C来完成的。


1.6 虚拟机栈(JVM Stacks)

虚拟机栈线程私有,生命周期与线程相同。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

img

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈动态扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

操作数栈(Operand Stack)也称作操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

方法返回:无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。


1.7 本地方法栈(Native Method Stacks)

本地方法栈(Native Method Stacks)与虚拟机栈作用相似,也会抛出StackOverflowError和OutOfMemoryError异常。

区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈是为虚拟机使用到的Native方法服务。


总结

img


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

概述:

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

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

策略1:引用计数法

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

img

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

策略2:可达性分析法

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

img

说到这,我们可以需要了解一下什么是GC Roots的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中Native方法引用的对象

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

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

回收方法区(元空间)

在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 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:分代回收

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

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

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

3.1 类加载机制

1.类的生命周期

img

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

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

当完成字节码文件的校验之后,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):负责加载<JAVA_HOME>\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库。
    2. 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

img

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

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

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


4.1 进程和线程、并发和并行

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

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

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

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

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


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

  1. 同步和异步

    它们关注的是消息通信机制,所谓同步就是在发出一个调用时,在没有得到结果前该调用就不返回。异步则相反,调用发出后,这个调用就直接返回了,也就是说调用者不会立即得到结果,而是事后被调用者通过状态或通知、回调函数来告诉通知者。

    例如:你打电话问书店老板有没有"xxx"书,老板说要你等一下,他去找,就一直找啊一直找,假设找了50s,这50s都没有挂电话,这就是同步。异步就是你打电话过去,他说找好了在告诉你,就挂了电话,找到后打电话告诉你就是一个通知或回调的方法。


4.3 synchronized and lock

两者的区别如下:

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)的使用。

synchronized的基本规则如下:

Synchronized的使用场景

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


常见的锁机制

重量级锁

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

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

自旋锁

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

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

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

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

轻量级锁

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

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

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

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

悲观锁和乐观锁

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

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


ThreadLocal解读

ThreadLocal 是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的,这样就可以避免资源竞争带来的多线程的问题,这种解决多线程的安全问题和lock(这里的lock 指通过synchronized 或者Lock 等实现的锁) 是有本质的区别的:

  1. lock 的资源是多个线程共享的,所以访问的时候需要加锁。
  2. ThreadLocal 是每个线程都有一个副本,是不需要加锁的。
  3. lock 是通过时间换空间的做法。
  4. ThreadLocal 是典型的通过空间换时间的做法。

当然他们的使用场景也是不同的,关键看你的资源是需要多线程之间共享的还是单线程内部共享的

发布了27 篇原创文章 · 获赞 32 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_39809458/article/details/104004426