当我们写了一个Java Demo程序,使用maven打包成demo.jar之后,实质上我们是把java源文件通过编译器变成了相应的class文件,也就是字节码文件。
即:.java文件 ——> 编译器编译 ——> .class文件
一些思考:
为什么要有编译这个动作?是因为我们写的代码是高级语言,而不是机器所能识别的01机器码,通过编译转换成机器可识别的机器码,然后机器就可以执行。
为什么Java要编译成class文件?这是为了解决不同机器或者不同平台的机器码是不同的,通过一个中间层的设计来屏蔽到底层的差异。而这个中间层就是JVM,JVM可以加载同样的class字节码文件运行在不同的平台上。
当然还有为什么Java为什么不一步到位编译成机器码,为什么JVM直接加载.java文件,这样设计的理由是什么?
当我们运行java程序的时候,执行 java -jar demo.jar
,实质上是启动了一个JVM实例,JVM加载了demo.jar中class文件。如果我们启动了多个jar包,那么就启动了多个JVM实例,这些实例之间互不干扰,数据不共享。
语言上我们说启动了一个JVM实例,但从感官上我们就是启动了一个程序,这个程序从硬盘上读取了一个jar文件,同时启动这个程序的时候后面增加了很多其它参数,而这些参数就是提供给我们以何种方式来运行这个程序。所以简单来说就是我们的硬盘上躺着一个程序,我们启动它的时候给它一个文件和一些参数。
当这个程序启动之后,就读取给定文件的所有内容,然后按照给定参数来运行程序。我们把这个程序运行起来的程序内部环境叫JVM,那个给定文件加载运行在这个环境,其它参数设定着JVM的运行模式。
这个程序内部不仅有JVM,还有一个子系统叫类加载系统,和JVM并列存在,和JVM可以交互。而类加载系统的目的是为了把class文件从硬盘上加载到内存中,方便之后的使用。因为在启动java程序的时候,所谓的代码文件还躺在硬盘上的一个位置,java程序只是知道他的路径而已。
那么如果我们设计一个类加载系统该如何设计呢?如果把这件事抽象想象成读取一个文件然后解析,最后解析结果输出到一个地方供人使用,就可以进行简单设计:
实际上类加载分为3部分:加载 ——> 连接 ( 验证 -> 准备 -> 解析 )——> 初始化
其实类加载的这3步每次都在和JVM交互,第一次读文件变成数据结构放在JVM中,第二次校验后把变量放到JVM中顺便整理符号引用为直接引用,第三次是把JVM中整个代码初始化。
整理一下,我们这部分做的内容概括来说就是通过类加载系统加载文件,然后它和JVM交互,把处理的内容放在了JVM的方法区。这里延伸一些,方法区也叫永久代,但是永久代经常出问题,所以JDK1.7将常量、静态变量放入堆中,JDK1.8将类信息、编译代码放入元空间中
更多请看:《【JVM】类加载机制》
代码准备好了,具体跑起来还需要具体的执行对象,这里的执行对象就是线程。
其实从硬件层面或者底层来看,运行程序就两件事情:程序 + 执行,当准备好程序放在内存之后,就交给CPU让其执行。但是硬件发展到今天这个地步,已经是多任务处理,从CPU这里来看,它可以单核多任务不停切换,对于程序而言,指的是多个进程轮流执行。而CPU不仅是一个核,所以可以并行处理,及时针对单个进程,也可以使用多个核来执行代码,那么这个进程里使用多个线程来占用多个核并行执行。
接着上面的话题,如果只是一个程序,它有多段代码可以同时执行,当CPU一个核运行了一段代码之后,代码告诉CPU它目前不用了或者阻塞了,那么CPU就去处理其他代码。
那么实际情况是怎样的?其实这个场景下CPU不是主体,具有能动性的是操作系统和一个个争夺CPU的对象,这些对象向操作系统申请占用CPU,而一旦处理完或暂时不用或使用时间到了,就得还回去,操作系统就给了其它对象。
这些对象就是线程。在JVM中,一个线程包含 虚拟机栈(每个栈帧:局部变量表、操作栈、动态链接、返回地址)、本地方法栈、程序计数器。如上文所言,线程包含着代码位置和变量值。不过它是面向方法设计的,每调用一个方法生成一个栈帧,记录着调用这个方法的信息。
更多请看:《【JVM】内存区域 & 对象创建定位》
在类加载系统把类加载准备好了,在相关线程启动了,在找到了Java程序的main方法后,代码开始运行了
这里插一句,在JVM的内存中,还有一个地方没有说到,那就是堆,它是运行时内存。
随着代码的运行,各个线程创建了一个又一个的对象,而这些对象就放在堆内存中。而堆内存和方法区对于各线程而言都是共享的。
那么就是随着一个个线程的启动,每个线程都拿到了属于自己的程序计数器、虚拟机栈、本地方法栈,冲向了方法区,拿到属于自己的代码位置,因为这些代码本身而言没有什么中间值,所以方法区对线程是共享的。然后线程开始争抢CPU,等待着操作系统的调度,抢到CPU的线程开始执行代码,把当前的字节码指令位置放在程序计数器中,然后一步步执行,执行方法过程中产生了对象,其实就是要一块内存存储这个方法中产生的变量,线程直接在堆内存申请空间创建了对象,然后继续执行,在执行到内嵌的一个方法时,把刚刚产生的信息放在了自己虚拟机栈的一个栈帧中,然后创建一个新的栈帧开始记录相关数据,执行完之后,出栈再继续上一个栈帧中的记录的执行位置。至于刚刚产生的对象申请的堆内存空间就随它去吧,会有其他人负责处理的。
就这样,读取代码、获取CPU、运行代码、保存临时对象、记录运行位置、让出CPU、等待继续执行 … 代码有条不紊被执行起来
说在前面:有些问题终究回避不了 …
在代码执行的过程中,产生的对象或者说申请的内存,是需要释放的,不释放内存迟早都会占满。其它的语言有的需要开发者来回收释放,而Java这边,是不需要开发者管的,所以,就有了垃圾回收机制。
其实在JVM中在主要运行的后台线程中就有GC线程,它就主要在堆内存进行回收处理。
GC主要做两件事情:哪些内存需要回收?怎么回收?
确定无用内存:通过引用计数法和可达性分析
怎么回收就有不同的策略:标记清除算法,复制算法,标记整理算法。这里把整个堆内存分为新生代和老年代,不同的区域使用不同的策略,美其名曰分代回收算法,其实就是新生代使用复制算法,因为大量对象方生方死,而老年代使用标记整理算法,因为每次回收对象少。
更多请看:《【JVM】垃圾收集》
说在前面:简单就可能会浪费,而避免浪费就会带来一定的复杂 …
单线程的时候没有什么复杂性,但是对硬件的利用率就很低,多线程对硬件的利用率很高,但是不可避免带来一些新的问题,例如多个线程对同一块堆内存的访问处理,就会带来数据不一致篡改什么的,所以,引入了一套新的机制来解决这个问题,那就是锁。
涉及到锁,就针对不同场景设计出了乐观锁、悲观锁、自旋锁、synchronize同步锁、ReentrantLock、公平锁、非公平锁、共享锁、独占锁、重量级锁、轻量级锁、偏向锁、分段锁,通过这些锁可以解决多线程下的并发问题。
更多请看:待完善…
本文只是整体流程的简单梳理,细节方面有不足或偏差。这么写的初衷就是想把这些很碎的知识串起来,因为记是记不住的,通过理解可以看到设计每块内容的面对的问题,以及如何巧妙的解决。
通过代码如何执行,梳理了一遍JVM的内存分配、类加载机制、垃圾回收、多线程、锁的相关知识。