Java虚拟机与并发编程学习

Java虚拟机与并发编程学习

前言:为什么要学习Java虚拟机?

书中观点:随着Java技术的不断发展,它已被应用于越来越多的领域之中。其中一些领域,如互联网、能源、金融、通信等,对程序的性能、稳定性和扩展性方面会有极高的要求。一段程序很可能在10个人同时使用时完全正常,但是在10000个人同时使用时就会缓慢、死锁甚至崩溃。毫无疑问,要满足10000个人同时使用,需要更高性能的物理硬件,但是在绝大多数情况下,提升硬件性能无法等比例提升程序的运行性能和并发能力,甚至有可能对程序运行状况没有任何改善。这里面有Java虚拟机的原因:为了达到“所有硬件提供一致的虚拟平台”的目的,牺牲了一些硬件相关的性能特性。更重要的是人为原因:如果开发人员不了解虚拟机诸多技术特性的运行原理,就无法写出最适合虚拟机运行和自优化的代码。

我的简单理解:了解Java虚拟机就是等于了解了Java程序的底层原理知识,可以帮助我们更加深刻的理解我们编写的程序,以及如何更好的用代码实现以及优化程序性能,如内存管理分配,多线程,高并发等。

了解:HotSpot虚拟机(热点代码探测技术)

它是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,也是目前使用范围最广的Java虚拟机。

自动内存管理

这是Java虚拟机的特性

1、运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域

image-20210722153819997

进程可以细化为多个线程。

每个线程拥有自己独立的栈,程序计数器

多个线程共享同一个进程中的结构(数据):方法区,栈

1.1、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的 字节码的行号指示器。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

每个线程都有自己的程序计数器。记录正在执⾏的虚拟机字节码指令的地址(如果正在执⾏的是本地⽅法则为空)。

。。

1.2、java虚拟机栈(栈)

虚拟机栈是线程私有的。每个线程在创建时都会创建一个虚拟机栈,其内部保存着一个个的栈帧(Stack Frame),对应着一次次的方法调用。

​ 每个 Java ⽅法在执⾏的同时会创建⼀个栈帧⽤于存储局部变量表、操作数栈、常量池引用等信息。从⽅法调⽤直⾄执⾏完成的过程,对应着⼀个栈帧在 Java 虚拟机栈中⼊栈和出栈的过程。

  • 可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存⼤⼩,如:-Xss1024k,栈的大小决定了函数调用的最大可达深度

  • 对于栈来说不存在垃圾回收问题

栈帧的内部结构

image-20211008162426275

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

变量槽

局部变量表的容量以变量槽(Variable Slot)为最小单位。一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、float、reference[1]和returnAddress这8种类型。对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java语言中明确的64位的数据类型只有long和double两种。

image-20211008221048899

如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用,如if或for语句中定义的变量占用的变量槽可供后面变量复用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为

补充说明

  • 在栈帧中,与性能调优最密切的部分就是局部变量表,因为局部变量表是决定一个栈帧大小的关键因素。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量中的变量直接或间接引用的堆中对象都不会被回收
操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储结构。

同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

工作机制:当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。举个例子,例如整数加法的字节码指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。

虚拟机实现中的栈帧重叠:让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了

image-20211008234104661

Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。

拓展内容

Java 基本数据类型存储位置:https://blog.csdn.net/ncuzengxiebo/article/details/83745065

img

8大基本数据类型都在栈中分配,引用数据类型则在堆中分配。

这些数据类型的变量都在栈中被创建。如int i = 10;String s = new String("hello")里的i和s都可称作变量,只是i直接指向JVM栈中的内存栈上的,数据本身的值就是存储在栈空间里面;而String类型变量s存放的是指向堆中对象的引用值,这里的引用值可以理解成计算机中的内存块地址,有这个唯一的引用值,才能找到堆中指定的对象。它们同样也是存放在栈空间中。

数组同样也是引用数据类型,数组类型的存储位置:

image-20210802150556202

栈溢出几种情况

局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。

递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。

1.3、本地方法栈

本地⽅法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地⽅法栈为本地⽅法服务。

本地⽅法⼀般是⽤其它语⾔(C、C++ 或汇编语⾔等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些⽅法需要特别处理。

1.4、方法区(包含运行时常量池)

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。它用于存储已被虚拟机加载的类型信息[1]、常量、静态变量、即时编译器编译后的代码缓存等数据

为了更容易管理⽅法区,从 JDK 1.8 开始,移除永久代,并把⽅法区移⾄元空间,它位于本地内存中,⽽不是

虚拟机内存中。

⽅法区是⼀个 JVM 规范,永久代与元空间都是其⼀种实现⽅式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放⼊堆中。

重点:

  • 方法区线程共享
  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • JDK 1.8 中方法区(method area)已经被元空间(metaspace)代替。
  • 字符串池在JDK 1.7 之后被分离到堆区。

注1:类型信息:

对每个加载的类型(类class,接口interface,枚举enum,注解annotation),JVM必须在方法区存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名
  • 这个类型的修饰符(public,abstract,final三者中的某个子集)
  • 这个类型直接接口的一个有序序列

参考链接:https://blog.csdn.net/qingtiantianqing/article/details/51405517

。。

1.5、Java堆(堆)

堆空间内部结构细分

将堆中内存逻辑上分为三部分:新生代+老年代+元空间(jdk8)

新生代又分为Eden区和Survivor区

image-20211029123502919

image-20211029123415051

堆空间大小的参数设置和查看
  1. -Xms 用来设置堆空间(新生代+老年代)的初始大小

    • -X 是jvm的运行参数;ms 是memory start的缩写
  2. -Xmx 用来设置堆空间(新生代+老年代)的最大内存大小

    默认情况下:初始内存大小为物理内存大小/64;最大内存大小为物理内存大小/4。单位GB。

    通常建议-Xms和-Xmx设置为相同大小。避免因为GC造成频繁的堆空间扩容释放而导致不必要的系统压力。如线程池线程的创建与回收造成的。如:-Xms20M、-Xmx20M

  3. -Xmn 设置新生代的大小(初始值及最大值)

  4. -XX:SurvivorRatio=8

    指代了新生代中Eden区与Survivor区的空间比例是8∶1:1。新生代总可用空间为Eden区+1个Survivor区的总容量。

  5. -XX:+PrintGCDetails(程序执行完后打印详细GC)

    -XX:+PrintGC(简要GC信息)

    指代收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。

  6. -XX:+PrintFlagsFinal : 查看所有的参数(包括修改后)

  7. -XX:+PrintFlagsInitial : 查看所以的默认参数

image-20211030213215472

详情参考2.3节

TLAB

​ 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率,避免某些线程安全问题。

不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

可视化工具查看进程内存分配

  1. jvisualvm

2、HotSpot虚拟机中的对象在Java堆分配、布局和访问

2.1、对象的创建

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,若没有则必须先执行相应的类加载器。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。有两种分配方式:“指针碰撞”和“空闲列表”,根据Java堆是否规整来决定选择哪种。而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。

​ 除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况

解决这个问题有两种可选方案:

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

​ 内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,作用是如类中字段未赋初始值情况下在对象实例化后也能直接使用。

​ Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才 计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

2.2、对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

对象头

对象的对象头部分包括两类信息。

第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。

另一类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

实例数据

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

2.3、对象在堆中的分配位置与GC机制

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC/Young GC(新生代收集)。

image-20211030181748698

过程:会将Eden区中不用的垃圾回收,将存活下的对象往s0中分配。幸存区(s0/s1)中的对象如果还被使用则将岁数加1,并交换幸存区。幸存区中对象岁数大于阈值时放入老年代。

大致过程概述:

image-20211030184006308

image-20211030201331873

通过日志案例分析GC

package testjvmCode;

import java.util.ArrayList;

/**
 * @Description: 通过日志测试堆空间的垃圾回收机制
 * @ProjectName: MyTestCode
 * @ClassName: TestHeap
 * @Author: YX
 * @Date: 2021/10/30 21:33
 */
public class TestHeap {
    private static final int _1MB = 1024 * 1024;
    /*** VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 */
    public static void main(String[] args) throws InterruptedException {
        byte[] allocation1, allocation2, allocation3, allocation4;
        Thread.sleep(3000);
        allocation1 = new byte[2 * _1MB];
        Thread.sleep(3000);
        allocation2 = new byte[2 * _1MB];
        Thread.sleep(3000);
        allocation3 = new byte[2 * _1MB];
        Thread.sleep(3000);
        // 出现一次Minor GC
        allocation4 = new byte[4 * _1MB];

    }
}

[GC (Allocation Failure) [PSYoungGen: 7231K->1000K(9216K)] 7231K->5208K(19456K), 0.0031222 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 7344K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 77% used [0x00000000ff600000,0x00000000ffc32028,0x00000000ffe00000)
  from space 1024K, 97% used [0x00000000ffe00000,0x00000000ffefa020,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 4208K [0x0000000090c00000, 0x0000000091600000, 0x00000000ff600000)
  object space 10240K, 41% used [0x0000000090c00000,0x000000009101c020,0x0000000091600000)
 Metaspace       used 3729K, capacity 4536K, committed 4864K, reserved 1056768K
  class space    used 410K, capacity 428K, committed 512K, reserved 1048576K

Process finished with exit code 0

[GC (Allocation Failure) [PSYoungGen: 7231K->1000K(9216K)] 7231K->5208K(19456K), 0.0031222 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

ps:这里[PSYoungGen: 7231K->1000K(9216K)]代表新生代GC后的空间大小,7231K->5208K(19456K)代表整个堆区经历这次GC的空间大小变化。

总结:

  • 针对幸存区(Survivor)s0,s1,复制之后(垃圾回收算法)有交换,谁空谁是to区。
  • 关于垃圾回收,频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

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

垃圾收集主要是针对堆和方法区进行。

引用概念

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  • ·软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

  • ·弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

  • ·虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用

如何判断一个对象要不要回收?(标记阶段)

常见的利用如下两种算法:

引用计数算法
可达性分析算法

​ 当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

这个算法的基本思路就是通过 一系列称为“GC Roots”根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

如下图:object5以及它相关联的对象不能经GCRoots根节点搜索到达,所以可判断将其回收

image-20211101230709614

垃圾收集算法(清除阶段)

​ 在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”,“Major GC”,“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”,“标记-清除算法”,“标记-整理算法”等针对性的垃圾收集算法。

​ 设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。新生代中可以较为频繁的触发垃圾回收机制,相反老年代剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单 独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

新生代(Young)、老年代(Old)是HotSpot虚拟机,也是现在业界主流的命名方式。

标记-清除算法

特点:实现简便

缺陷:易产生空间碎片的复杂情况,不利于后面的大对象内存申请等。

标记-复制算法

​ 为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

特点:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

缺陷:这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

image-20211101174901024

标记-整理算法

​ 标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法(因为老年代存放的大都是需要长久存活和访问的对象呀!)。

特点:基于标记-清除算法后,再将空间里的存回对象整理移动一遍

缺陷:如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作。

“和稀泥式”解决方案

特点:是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。等于结合清除加整理算法的长处。

hotspot分代回收算法

​ 现在的商业虚拟机采⽤分代收集算法,它根据对象存活周期将内存划分为⼏块,不同块采⽤适当的收集算法。

关键:

  • 新生代(Eden)大量对象死去,少量存活,所有采用标记复制算法
  • 老年代存活率高,回收较少,所有采用标记-清除 或者 标记-整理 算法

新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

特点:Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden区和其中一块Survivor区。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

当Survivor空间不足以容纳一次Minor GC(新生代收集)之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

image-20211030181748698

STW(Stop The World)

可达性分析算法中枚举根节点(CG Root)会导致所有Java执行线程停顿。

  • 分析工作必需在一个能够保证引用一致性的快照中进行
  • 如果分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
  • 被SWT中断的应用程序会在完成GC之后回复
  • STW事件和采用哪款GC无关,所有的GC都有这个事件
  • STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉
  • System.gc()会导致STW

经典垃圾收集器

垃圾回收器分类

  • 线程数分,可以分成串行垃圾收集器和并行垃圾收集器
  • 工作模式分,可以分成并发式和独占式垃圾回收器
    • 独占式STW会暂停应用程序所有线程,直到GC结束
    • 并发式垃圾回收线程与应用程序线程交替工作,尽可能减少应用程序停顿时间
  • 碎片处理方式分,可分为压缩式和非压缩式
    • 压缩式会在回收完成后,对存活对象进行压缩整理,消除回收的碎片,再分配对象空间使用:指针碰撞方式
    • 非压缩则不进行上述步骤,分配对象空间使用:空闲列表方式(虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”)
  • 按工作内存区间分,可分为年轻代和老年代垃圾回收器

评估GC的性能指标

  • 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间=程序运行时间+内存回收时间)
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
  • 内存占用:Java堆区所占的内存大小
  • 垃圾收集开销

Serial收集器

这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)[1]最小的;对于单核处理

器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以

获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚

拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的

内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一

百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的

类文件

java虚拟机与Class文件提供的语言无关性

​ 实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。基于安全方面的考虑,《Java虚拟机规范》中要求在Class文件必须应用许多强制性的语法和结构化约束,但图灵完备的字节码格式,保证了任意一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。

​ 作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品 的交付媒介。例如,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以把它们的源程序代码编译成Class文件。虚拟机丝毫不关心Class的来源是什么语言,它与程序语言之间的关系如图6-1所示。

image-20210813165627082

Java文件编译解释过程,热点代码执行机制(也可称Java是半编译半解释型的语言)

image-20210813104939822

Class文件的本质

任何一个Class文件都对应着唯一一个类和接口的定义信息,但反过来说,Class文件实际上它不一定以磁盘文件的形式存在。 Class文件是一组以8个字节为基础单位的二进制流。

根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”

无符号数和表

  • ·无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • ·表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由表6-1所示的数据项按严格顺序排列构成。

Class类文件的结构

  1. 魔数与Class文件的版本。每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。

  2. 常量池

    tips:这里指的常量池全称应该叫静态常量池更好理解。常量池分为静态常量池、运行时常量池。
    静态常量池在 .class 中,运行时常量池在方法区中。JDK 1.8 中方法区(method area)已经被元空间(metaspace)代替。
    字符串池在JDK 1.7 之后被分离到堆区。

类的加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

类的生命周期

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如图7-1所示。

image-20210813171621816

类的加载过程

广义上看有三个阶段,即上图中的加载(狭义)连接初始化三个阶段。

  1. 加载

​ 加载过程完成以下三件事:

  • 通过类的完全限定名称获取定义该类的⼆进制字节流。

  • 将该字节流表示的静态存储结构转换为⽅法区的运⾏时存储结构。

  • 在内存中⽣成⼀个代表该类的 Class 对象,作为⽅法区中该类各种数据的访问⼊⼝。

  1. 链接

    • 验证

    • 准备

      类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使⽤的是⽅法区的内存。

      实例变量不会在这阶段分配内存,它会在对象实例化时随着对象⼀起被分配在堆中。应该注意到,实例

      化不是类加载的⼀个过程,类加载发⽣在所有实例化操作之前,并且类加载只进⾏⼀次,实例化可以进

      ⾏多次。

      初始值⼀般为 0 值,例如下⾯的类变量 value 被初始化为 0 ⽽不是 123。

    • 解析

      将常量池的符号引⽤替换为直接引⽤的过程。

      其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了⽀持 Java 的动态绑定。

  2. 初始化

    将准备阶段分配好内存的类变量赋上我们指定的值。

类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相 等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

拓展相关问题:JVM在搜索类的时候,是如何判断两个class是相同的呢?

启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在 <JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。

双亲委派模型

站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现[1],是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

开发的角度来看,有三种类加载器:

  1)启动类加载器(Bootstrap ClassLoader):这个类加载器主要是负责加载${JAVA_HOME}/lib目录的jar(比如rt.jar、resources.jar)或者被-Xbootclasspath参数所指定的路径中的jar。

  (调用ClassLoader类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。)

  2)扩展类加载器(Extension ClassLoader):它负责加载${JAVA_HOME}/lib/ext目录或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

  3)应用类加载器(Application ClassLoader):这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,也就是调用ClassLoader.getSystemClassLoader()可获取该类加载器,所以又叫系统类加载器。它负责JVM启动时加载来自命令java中的-classpath或者java.class.path系统属性或者CLASSPATH操作系统属性所指定的JAR类包和类路径。加载用户类路径(ClassPath)上所指定的类库。用户自定义的任何类加载器都将该类加载器做为它的父类加载器。

如下图所示:

image-20211004214410711

上图中展示的各种类加载器之间的层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

工作过程

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

为什么要用双亲委托模型

  1. 因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

  2. 考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

外链:https://blog.csdn.net/qq_27828675/article/details/109514389

Java内存模型与线程

Java内存模型

《Java虚拟机规范》中曾试图定义一种“Java内存模型”(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序。

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

主内存与工作内存

image-20210826161019607

如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储 于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

理解:Java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝,所以程序在执行过程中,一个线程看到的变量并不一定是最新的。因为程序运行时主要访问工作内存,即主内存的变量的拷贝值。像堆中的实例对象,静态字段或数组等共享变量。当需要用到工作内存时,将其变量值拷贝取出,操作后再同步进去。

详情在深入理解Java虚拟机12.3.1中

内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。

lock、unlock、read、load、assign、use、store、write。这八种操作都具有原子性。

原子性、可见性与有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性。

1.原子性(Atomicity)

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。

如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

2.可见性(Visibility)

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。使用volatile变量的时候可以达到这一目的。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。

3.有序性(Ordering)

​ Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial

Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

指令重排序是因为考虑到CPU执行性能与优化问题,工程师们发明了流水线技术。由于每个汇编指令是分成多个步骤执行,使用的硬件也不同,便可以让这些硬件充分的运行从而减少指令的串行运算造成得等待。

注:一条汇编指令的执行大概可以分为如下几步:取指 IF、译码和取寄存器操作数 ID 、执行或者有效地址计算 EX、存储器访问 MEM 、写回 WB

但一个计算过程总会有需要等待的时候,如a=b+c;d=e+f;但b和c的Load指令(LW,表示将内存中变量的值加载到寄存器)可能还未完成, 那么a的相加指令就需要等待,这便是流水线中断。

image-20211026181841950

而指令重排正是为了尽可能多的减少流水线中断问题,从而达到提高CPU处理效率的目的。

先行发生原则

image-20211026182608522

时间先后顺序与先行发生原则之间基本没有因果关系, 所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

对于volatile型变量的特殊规则

volatile变量特性

当一个变量被定义成volatile之后,它将具备两项特性:可见性禁止指令重排序优化

  1. 第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

    volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于线程每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题)

    而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。

    但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。如i++,它不是一个原子操作[1],即使i是voaltile变量,也需要使用synchronized 给它“上锁”。

  2. 使用volatile变量的第二个语义是禁止指令重排序优化,指令重排序可能会导致并发编程中的操作错误

注:

  • [1]i++为什么不是一个原子操作,即使加了volatile关键字?

    答:原因是i++这行自增代码在经过编译后Class文件中的字节码指令为iconst_1、iadd两条。在多线程环境操作下,操作前取到的值是正确的毫无疑问,但多核处理器并行情况下,有可能同一个值被多个操作数栈同时执行,执行完写回主内存时便造成冲突,自增结果往往会小于预期值。

    参考代码:

    public static void increase() { race++; }
    
    //字节码文件
    public static void increase(); 
    	Code:
            Stack=2, Locals=0, Args_size=0 0: getstatic #13; //Field race:I 
            3: iconst_1 
            4: iadd 
            5: putstatic #13; //Field race:I 
            8: return 
        LineNumberTable: 
    		line 14: 0 
       		line 15: 8
    

上述两者特性是如何实现的呢?

观察volatile修饰的变量的赋值过程的汇编代码:

关键变化在于有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便 是赋值操作)多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置)。这里的关键在于这个lock前缀

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(防止多cpu情况下指令重排序造成线程安全问题)

  2)它会强制将对线程工作内存中的volatile变量修改操作立即写入主存(可见性)

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效,其他线程便会重新从主内存中获取最新的值;。

应用场景

列举几个Java中volatile常用的几个场景。

1. 状态标记量

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}

2. 单例模式的双重检验锁

public class Singleton {

        private volatile static Singleton instance;
        public static Singleton getInstance() {
            if (instance == null) {
                synchronized (Singleton.class) {
                    if (instance == null) {
                        instance = new Singleton();//可能出现重排序问题
                    }
                }
            }
            return instance;
        }

        public static void main(String[] args) {
            Singleton.getInstance();
        }

}

为什么一定要加volatile关键字?

主要问题出在创建对象并设置引用变量那一步。这一行代码可以分解为如下的3行伪代码。

memory = allocate(); // 1:分配对象的内存空间 

ctorInstance(memory); // 2:初始化对象 

instance = memory; // 3:设置instance指向刚分配的内存地址 

在某些编译器上,可能会发生23顺序颠倒的可能。假设有A线程执行完3步骤后instance!=null,就有一个B线程跳过第一层null检查,直接访问该对象了,而此时2还未执行,B线程将会访问到一个还未初始化的对象。

添加上volatile后,执行效果如下:

image-20211026204208740

这个方案本质上是2和3之间的重排序,来保证线程安全的延迟初始化。

注:具体请参考Java并发编程的艺术3.8章节

总结

​ synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

​ volatile变量量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低。

面试回答:而volatile修饰的变量,保证了CPU各个核心不会从线程的工作内存中读数据,而是直接从主内存(堆内存)中读数据。而且写操作会直接写回主内存中,从而保证了多线程间共享变量的可见性和局部顺序性(但不保证原子性)

参考博客:https://www.cnblogs.com/xuxinstyle/p/10731189.html

相关验证在深入理解Java虚拟机12.3.3节中

Java与线程

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。目前线程是Java里面进行处理器资源调度的最基本单位。

主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。

ps:拓展了解内核

内核,是一个操作系统的核心。是基于硬件的第一层软件扩充,提供操作系统的最基本的功能,是操作系统工作的基础,它负责管理系统的进程、内存、设备驱动程序、文件和网络系统,决定着系统的性能和稳定性。

现代操作系统设计中,为减少系统本身的开销,往往将一些与硬件紧密相关的(如中断处理程序、设备驱动程序等)、基本的、公共的、运行频率较高的模块(如时钟管理、进程调度等)以及关键性数据结构独立开来,使之常驻内存,并对他们进行保护。通常把这一部分称之为操作系统的内核

参考链接:https://baike.baidu.com/item/内核

内核线程实现

使用内核线程实现的方式也被称为1:1实现。

​ 内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上

​ 轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

这这种轻量级进程与内核线程之间1:1 的关系称为一对一的线程模型,如下图所示。

轻量级进程与内核线程之间1:1的关系

image-20210902115304630

局限性:

  • 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
  • 每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

ps:内核线程的调度成本主要来自于用户态与内核态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。

用户线程实现

使用用户线程实现的方式被称为1:N实现。

广义上来讲,一个线程只要不是内核线程,都可以认 为是用户线程(User Thread,UT)的一种。

而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存 在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

进程与用户线程之间1:N的关系

image-20210902155201068

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。

优势:

  • 管理开销小:创建、销毁不需要系统调用。
  • 切换成本低:用户空间程序可以自己维护,不需要走操作系统调度。
  • 线程操作效率高:线程不需要切换到内核态,因此操作可以是非常快速且低消耗的

局限性:

  • 没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是程序必须考虑的问题。
  • 由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”,“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。

Java线程的实现

​ 内核线程实现,一对一映射。“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。

以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。

HotSpot JVM主要的后台系统线程有哪些?

  • 虚拟机线程
  • 周期任务线程
  • GC线程
  • 编译线程
  • 信号调度线程

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

等待wait()和通知notify()方法属于Object类

线程安全与锁优化

线程安全是什么

​ 对于一个对象来说,以这个对象需要被多个线程同时操作为前提,并且线程调用这个对象的行为都能得到正确的结果,就可以称这个对象是线程安全的。

《Java并发编程实战(Java Concurrency In Practice)》的作者Brian Goetz为“线程安全”做出了一个比较恰当的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”

这个定义就很严谨而且有可操作性,它要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用

线程安全的实现方法

1.互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

策略:悲观的并发策略。即总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁。

实现方式基本有如下两种:

synchronized关键字

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

关于synchronized的直接推论,这是使用它时需特别注意的:

  • ·被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
  • ·被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。主流虚拟机中,java线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程则需要操作系统的帮助,这就不可避免的需要在用户态和内核态的切换中消耗处理器的时间。尤其是对于代码比较简单的同步块,状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。

JUC包下的Lock接口

2.非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。从解决问题的方式上看,互斥同步属于一种悲观的并发策略

随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程

CAS(Compare-and-Swap)比较和交换

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

3.无同步方案

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。

同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。

简要记录两类:可重入代码、线程本地存储(Thread Local Storage)

线程本地存储(Thread Local Storage)

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

最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。

其他

尚硅谷视频学习

p01-p203 ,p266-p301

课件总结:https://blog.csdn.net/qq_42449963/article/details/113965228

posted @ 2022-02-28 17:19  Y鱼鱼鱼Y  阅读(67)  评论(0编辑  收藏  举报