推广 热搜: csgo  vue  angelababy  2023  gps  新车  htc  落地  app  p2p 

沉淀再出发:jvm的本质

   2023-08-18 网络整理佚名1300
核心提示:现在我们知道:其实多线程根本的问题只有一个:线程间变量的共享,这里的变量,指的就是类变量和实例变量,后续的一切,都是为了解决类变量和实例变量共享的安全问题。通过对jvm的学习,我们可以深刻地理解到程序的执行原理,以及背后的内存和CPU的处理情况,对我们理解多线程,高并发,内存管理,内存优化,代码优化等有着重要的作用。

一、简介

关于jvm,用到的地方太多了。 从字面意思我们都可以理解,这也是一个虚拟机,所以会用其他虚拟机来运行其他操作系统,但是jvm是实现的,可以在不使用操作系统上运行相同的字节码文件来实现代码可移植性。 可以看看编译原理就知道,jvm运行代码的本质就是将字节码文件(中间代码)转换成最终适合不同平台的机器码。

同时jvm中有很多概念,肯定和编译系统有关,如何存储数据和代码,数据分为什么类型,需要什么格式存储,栈等,还有如相关数据的生命周期,垃圾回收机制,由此产生的一系列问题,函数的存储和调用,理解到这个程度,我们就可以更好的理解其他用java开发的软件,比如等等。进程和线程、并发让我们有了更深入的理解。

2.jvm初步学习

2.1、java平台

Java平台是由Java虚拟机和Java应用程序接口构建而成,Java语言是进入这个平台的通道。 用Java语言编写和编译的程序可以在这个平台上运行。 该平台的结构如下图所示:

2.2、JVM架构

1)类加载器()(用于加载.class文件)

2)执行引擎(执行字节码,或者执行方法)

3)运行时数据区(方法区、堆、java栈、PC寄存器、本地方法栈)

2.3、JVM生命周期

  • 启动:启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public
    static void main(String[] args) 函数的class都可以作为JVM实例运行的起点。
  • 运行:main()作为该程序初始线程的起点,任何其他线程均由该线程启动。
  • 消亡:当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,

程序也可以使用Runtime类或者System.exit()来退出。

运行中的Java虚拟机有一个明确的使命:执行Java程序。 它在程序开始执行时运行,在程序结束时停止。 在同一台机器上运行三个程序,就会有三个运行的Java虚拟机。 Java 虚拟机始终以 main() 方法开始,该方法必须是公共的、返回 void 且仅接受字符串数组。 程序执行时,必须向Java虚拟机指定main()方法的类名。 main()方法是程序的起始点,执行的线程被初始化为初始线程,程序中的其他线程都由它启动。

Java中有两种类型的线程:守护线程()和普通线程(非)。 守护线程是Java虚拟机本身使用的线程。 例如,负责垃圾收集的线程是守护线程。 当然,你也可以将自己的程序设置为守护线程,并且包含main()方法的初始线程不是守护线程。 只要Java虚拟机中有普通线程在执行,Java虚拟机就不会停止。 如果有足够的权限,可以调用exit()方法来终止程序。

2.4、JVM运行时数据区

Java堆(Heap)

所有线程共享的内存区域,虚拟机启动时创建用于存储对象实例,堆的大小可以通过-Xmx和-Xms控制; 异常:当堆中没有内存来完成实例分配,并且堆不能再扩展时。 java堆是垃圾收集器管理的主要区域。 java堆还可以细分为:新生代(New/Young)、老代/老代(Old/)。 持久代()在方法区,不属于Heap。

新生代:新建的对象都由新生代分配内存。常常又被划分为Eden区和Survivor区
。Eden空间不足时会把存活的对象转移到Survivor。新生代的大小可由-Xmn控制,也可用-XX:SurvivorRatio控制Eden和Survivor的比例。
旧生代:存放经过多次垃圾回收仍然存活的对象。
持久代:存放静态文件,如Java类、方法等;持久代在方法区,对垃圾回收没有
显著影响。

方法区

线程间共享,用于存储已被虚拟机加载的类信息、常量、静态变量以及即时编译器(JIT)编译的代码等数据; 异常:当方法区无法满足内存分配要求时。 JVM使用持久代()来存储方法区。

运行时常量池:方法区的一部分,用于存储编译过程中生成的各种文字和符号引用,比如类型常量就存储在常量池中; 异常:当常量池无法再申请内存时。

java虚拟机栈(VM Stack)

线程是私有的,生命周期与线程相同; 存储方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。 java方法执行的内存模型在执行每个方法的同时创建一个堆栈帧。 每个方法被调用直到执行完成的过程对应着虚拟机栈中一个栈帧从入栈到出栈的过程。

  • StackOverflowError异常:当线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError异常:如果栈的扩展时无法申请到足够的内存

JVM 堆栈是线程私有的。 每个线程同时创建一个 JVM 堆栈。 JVM 堆栈存储当前线程中的本地基本类型变量、部分返回结果和 Stack frame。 其他引用类型的对象在JVM堆栈上只存储变量名和指向堆上对象实例的首地址。

原生方法栈(Stack)

与虚拟机栈类似,主要服务于虚拟机所使用的方法。 在虚拟机中,本地方法栈和虚拟机栈直接合二为一。 用于支持方法执行,存储每个方法调用的状态。 对于方法接口来说,不需要实现JVM就有其支持,甚至根本不需要。 Sun 对Java 本机接口(JNI) 的实现是出于可移植性的考虑。 当然,我们也可以设计其他本地接口来代替Sun的JNI。 但这些设计和实现都是比较复杂的事情,并且需要保证垃圾收集器不会释放正在被本地方法调用的对象。

程序计数器 ( )

当前线程执行的字节码的行号指示符,为当前线程私有,不会出现。 程序计数器是一个很小的内存空间,充当当前线程正在执行的字节码的行号的指示器。 在虚拟机的概念模型中,字节码解释器工作时,通过改变这个计数器的值来选择下一条要执行的字节码指令。 需要分支、循环、跳转、异常处理、线程恢复等基本功能。 靠这个计数器来完成。

如果线程正在执行Java方法,则该计数器记录正在执行的虚拟机的字节码指令的地址;

如果该方法正在执行,则该计数器的值为空(); 程序计数器记录的字节码指令的地址,但是本地(eg:.()/long();)方法大多是用C实现的,并没有编译成需要执行的字节码指令所以当然有计数器中的empty()。

让我们看一个例子:

1 public class ZyrCal {  2     public static void main(String [] args){ 3              System.out.println(calc()); 4     } 5     public static int calc(){  6        int a = 100; 7        int b = 200; 8        int c = 300;  9        return ( a + b ) * c; 10     }11  }

该方法的多线程实现

该方法是通过调用系统指令来实现的,系统如何实现多线程就是如何实现的。 Java 线程总是需要以某种形式映射到操作系统线程。 映射模型可以是1:1(原生线程模型)、n:1(绿色线程/用户态线程模型)、m:n(混合模型)。 以VM的实现为例,目前大多数平台上都采用1:1的模型,即每个Java线程直接映射到一个OS线程来执行。 此时,该方法是由原生平台直接执行的,无需关注抽象JVM层面的“pc寄存器”概念。 原生CPU上真正的PC寄存器就是这样,就像多线程程序一样。

直接存储器 ( )

直接内存不是虚拟机操作的一部分,也不是Java虚拟机规范中定义的内存区域,但这部分内存也被频繁使用; NIO可以使用函数库直接在堆外分配内存,堆中的对象作为这块内存的引用来进行操作。 大小不受 Java 堆大小的限制,而是受本机(服务器)内存的限制。 例外:当系统内存不足时。

  • Java对象实例存放在堆中;
  • 常量存放在方法区的常量池;
  • 虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据放在方法区;
    以上区域是所有线程共享的。
  • 栈是线程私有的,存放该方法的局部变量表(基本类型、对象引用)、操作数栈、
    动态链接、方法出口等信息。
  • 一个Java程序对应一个JVM,一个方法(线程)对应一个Java栈。

2.4、Java代码编译和执行过程

Java代码的编译和执行包括三个重要机制:

(1)Java源码编译机制(.java源代码文件 -> .class字节码文件)
(2)类加载机制(ClassLoader)
(3)类执行机制(JVM执行引擎)

2.4.1、Java源码编译机制

Java源代码无法被机器识别。 它需要由编译器编译成JVM可以执行的.class字节码文件,然后由解释器解释运行。 即Java源文件(.java)--Java编译器-->Java字节码文件(.class)-->Java解释器-->执行。 流程图如下:

字节码文件 (.class) 与平台无关。 Java 中字符仅以一种形式存在。 字符转换发生在 JVM 和 OS 的交界处(/)。 最终生成的class文件由以下部分组成:

  • 结构信息:包括class文件格式版本号及各部分的数量与大小的信息
  • 元数据:对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口
    的声明信息、域与方法声明信息和常量池。
  • 方法信息:对应Java源码中语句和表达式对应的信息。包含字节码、异常处理
    器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。

2.4.2、类加载机制()

Java程序是由多个独立的类文件组成的。 这些类文件并不是一下子全部加载到内存中的,而是根据程序一步步加载的。 JVM的类加载是通过其子类来完成的。 类的层次关系和加载顺序可以用下图来描述:

1、Bootstrap ClassLoader

JVM的根ClassLoader,由C++实现, 加载Java的核心API:$JAVA_HOME中
jre/lib/rt.jar中所有class文件的加载,这个jar中包含了java规范定义的所有接口以及
实现。JVM启动时即初始化此ClassLoader.
2、Extension ClassLoader
加载Java扩展API(lib/ext中的类)
3、App ClassLoader
加载Classpath目录下定义的class
4、Custom ClassLoader
属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据
J2EE规范自行实现ClassLoader。

双亲委派机制:
JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归。如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

作用:1)避免重复加载;2)更安全。

如果不是双亲委派,那么用户在自己的classpath编写了一个java.lang.Object的类,那就无法保证Object的唯一性。所以
使用双亲委派,即使自己编写了,但是永远都不会被加载运行。

破坏双亲委派机制:
双亲委派机制并不是一种强制性的约束模型,而是Java设计者推荐给开发者的类加
载器实现方式。线程上下文类加载器,这个类加载器可以通过java.lang.Thread类
的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从
父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那么这个
类加载器就是应用程序类加载器。像JDBC就是采用了这种方式。这种行为就是
逆向使用了加载器,违背了双亲委派模型的一般性原则。

2.4.3、类执行机制

Java字节码的执行是由JVM执行引擎完成的。 流程图如下:

JVM 基于堆栈架构执行类字节码。 线程创建后,会生成程序计数器(PC)和堆栈(Stack)。 程序计数器存储了方法中下一条要执行的指令的偏移量,而堆栈每个堆栈帧对应每个方法的每次调用,堆栈帧由两部分组成:局部变量区和操作数栈。 局部变量区用于存放方法中的局部变量和操作数栈。 参数,操作数栈用于存储方法执行过程中产生的中间结果。

主要执行技术:解释、即时编译、自适应优化、芯片级直接执行。

  • 解释属于第一代JVM
  • 即时编译JIT属于第二代JVM
  • 自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二
    代JVM的经验,采用两者结合的方式开始对所有的代码都采取解释执行的方式,并监视代码执行情况。
    对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行优化。
    若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。

JIT 的工作原理

JIT就是just in time,即时编译技术。 利用该技术,可以加快java程序的运行速度。 当启用 JIT 编译时(默认启用),JVM 会读取 .class 文件进行解释并将其发送到 JIT 编译器。 JIT 编译器将字节码编译为本机机器代码。 通常javac将程序源代码编译成java字节码,JVM通过解释字节码将其翻译成相应的机器指令,一一读入,一一解释翻译。 很明显,解释运行后,其运行速度肯定比可运行的二进制字节码程序要慢。 为了提高运行速度,引入了JIT技术。 在执行过程中,JIT会保存翻译后的机器码以供下次使用。 因此,理论上来说,使用这种JIT技术可以接近之前的纯编译技术。 JIT 并不总是有效,并且不能指望 JIT 能够加快代码速度,更糟糕的是,它可能会减慢代码速度。 这取决于你的代码结构,当然,很多情况下我们仍然可以获得我们想要的。

从上面我们知道JIT被关闭的原因是java.lang..(); 是由于加快了运算速度。 由于JIT编译每个字节码,因此编译过程负担过重。 为了避免这种情况,目前的JIT只编译经常运行的字节码,比如循环等。

2.5、JVM垃圾收集(GC)

GC的基本原理:回收内存中不再被引用的对象(垃圾),GC中用于回收的方法称为收集器。 由于GC需要消耗一些资源和时间,因此在分析了对象的生命周期特征后,Java按照新生代和老年代的方式来收集对象,从而尽可能缩短GC给应用程序带来的停顿。

  • 对新生代的对象的收集称为minor GC;
  • 对旧生代的对象的收集称为Full GC;
  • 程序中主动调用System.gc()的GC为Full GC。

Java 垃圾收集由单独的后台线程 gc 执行,该线程自动运行,无需显式调用。 即使主动调用java.lang..gc(),该方法也只会提醒系统进行垃圾回收,但系统可能不会响应,可能会忽略它。

判断一块内存空间是否满足回收标准:

(1)对象赋予了空值,且之后再未调用(obj = null;)
(2)对象赋予了新值,即重新分配了内存空间(obj = new Obj();)

内存泄漏:程序保留对不再使用的对象的引用。 因此,这些对象并没有被GC回收,只是一直占用着内存空间却没有什么用处。 即:1)该对象可达; 2)该对象是无用的。 如果满足这两个条件,就可以判断为内存泄漏。

内存泄漏的原因:1)全局收集; 2)缓存; 3)。

不必要的对象应该变得不可访问,通常通过将对象字段设置为 null 或从容器中删除对象来实现。 局部变量不再使用时不需要显式设置为 null,因为对局部变量的引用在方法退出时会自动清除。

2.6。 内存调整

调优目的:减少GC的频率,特别是Full GC的次数,过多的GC会占用大量的系统资源,影响吞吐量。 请特别注意 Full GC,因为它会整理整个堆。

主要方法:JVM调优通过配置JVM参数来提高垃圾回收的速度,合理分配堆内存各部分的比例。

导致Full GC的几种情况和调优策略:

  • 旧生代空间不足
  • 调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象
  • 持久代(Pemanet Generation)空间不足
  • 增大Perm Gen空间,避免太多静态对象
  • 统计得到的GC后晋升到旧生代的平均大小大于旧生代剩余空间
  • 控制好新生代和旧生代的比例
  • System.gc()被显示调用
  • 垃圾回收不要手动触发,尽量依靠JVM自身的机制
  • 堆内存比例不良设置导致的后果:

1)新生代设置过大
一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC;
二是新生代GC耗时大幅度增加;一般说来新生代占整个堆1/3比较合适;

2)Survivor设置过大
-XX:MaxTenuringThreshold=n来控制新生代存活时间,尽量让对象在新生代被回
收。

JVM提供了两种比较简单的方式来设置GC策略:

1)吞吐量优先
JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例
,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置。

2)暂停时间优先
JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比
例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值
可由-XX:MaxGCPauseRatio=n来设置。

JVM常用配置:

1 堆设置2         -Xms:初始堆大小3         -Xmx:最大堆大小4         -XX:NewSize=n:设置年轻代大小5         -XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/46         -XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/57         -XX:MaxPermSize=n:设置持久代大小8 收集器设置9         -XX:+UseSerialGC:设置串行收集器10         -XX:+UseParallelGC:设置并行收集器11         -XX:+UseParalledlOldGC:设置并行年老代收集器12         -XX:+UseConcMarkSweepGC:设置并发收集器13 垃圾回收统计信息14         -XX:+PrintGC15         -XX:+PrintGCDetails16         -XX:+PrintGCTimeStamps17         -Xloggc:filename18 并行收集器设置19         -XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。20         -XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间21         -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)22 并发收集器设置23         -XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。24         -XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

3.多线程下的jvm

java中的变量可以分为三类:

  • 类变量(类里面static修饰的变量)
  • 实例变量(类里面的普通变量)
  • 局部变量(方法里声明的变量)

根据各个区域的定义,我们可以知道:

  • 类变量 保存在“方法区”
  • 实例变量 保存在“堆”
  • 局部变量 保存在 “虚拟机栈”

“方法区”和“堆”都属于线程共享数据区,“虚拟机栈”属于线程私有数据区。

因此,局部变量不能被多个线程共享,而类变量和实例变量可以被多个线程共享。 事实上,在java中,多线程之间通信的唯一方式就是通过类变量和实例变量。 也就是说,如果一个多线程程序中没有类变量和实例变量,那么这个多线程程序一定是线程安全的。

以Web开发的Servlet为例,一般我们开发的时候,自己的类继承HttpServlet之后
,重写doPost()、doGet()处理请求,不管我们在这两个方法里写什么代码,只要
没有操作类变量或实例变量,最后写出来的代码就是线程安全的。如果在Servlet类里面加了实例变量,就很可能出现线程安全性问题,
解决方法就是把实例变量改为ThreadLocal变量,而ThreadLocal实现的含义就是让
实例变量变成了“线程私有”的,即给每一个线程分配一个自己的值。

现在我们知道:其实多线程只有一个基本问题:线程间变量的共享。 这里的变量指的是类变量和实例变量。 接下来的一切都是为了解决共享类变量和实例变量的安全问题。

四。 概括

通过对jvm的学习,我们可以深入了解程序的执行原理,以及背后内存和CPU的处理,这对于我们理解多线程、高并发、内存管理、内存等都有重要的作用。优化、代码优化等

参考

欢迎大家加入极客星球,学习优秀的开源软件,修炼内功,提高核心竞争力。 星球将分享重要技术方向的核心技术,如何学习和掌握后台核心技术:编程语言的核心知识、Linux系统及内核、网络、算法、计算机组成原理等; 深入理解基本核心概念,夯实基本功,帮助大家练好内功,拓展大家的技术视野,解决技术难题,带领大家长期坚持学习,掌握核心技术,让自己的事业更上一层楼。 如果您对星球感兴趣,点击查看->:

-结尾-

看完后一键连读三遍、转发、点赞

是对文章最大的赞赏,极客重生谢谢

 
反对 0举报 0 收藏 0 打赏 0评论 0
 
更多>同类资讯
推荐图文
推荐资讯
点击排行
网站首页  |  关于我们  |  联系方式  |  使用协议  |  版权隐私  |  网站地图  |  排名推广  |  广告服务  |  积分换礼  |  网站留言  |  RSS订阅  |  违规举报
Powered By DESTOON