深入浅出JVM之JVM运行机制

一、JVM启动流程

启动jvm首先在命令窗口输入java命令,该命令会根据当前路径和系统版本寻找jvm.cfg(该配置文件一般在JAVA_HOME/jre/lib/amd64文件夹下),来加载jvm的配置文件,根据这个配置文件寻找jvm.dll(JAVA_HOME\jre\bin\server),jvm.dll是jvm的主要实现。然后初始化JVM获得JNIEnv接口,java中通过findClass等方法就是通过该功能实现的。然后再寻找指定类或jar包中的main方法,并运行。

二、jvm内部结构

首先class文件通过类加载器子系统加载到内存中,内存中分为方法区、Java堆、Java栈和本地方法栈。通过PC寄存器来保存下一条语句的指针,执行引擎用来执行相应的java字节码。本地方法栈通过本地方法接口可以调用本地方法。本地方法一般是由C或者C++编写的方法,由JVM来实现。然后还有一个垃圾收集器,用来回收垃圾。我们主要关心方法区、Java堆和Java栈以及其相关的内容。

2.1 PC寄存器:

每个线程拥有一个PC寄存器,在线程创建时创建,指向下一条地址,执行本地方法的时候PC寄存器的值是undefined。

2.2 方法区:

用来保存类的元信息的,保存一些class信息,包括类的常量池,字段和方法信息,以及方法字节码。在jdk6时,String等常量信息还是放在方法区的,但是到jdk7的时候,该常量信息就移动到了堆里面。

通常和永久区(Perm)关联在一起,保存一些相对静止和稳定的数据。永久区的数据也是可以被回收的,并不是永远固定不变的。

2.3 Java堆:

java堆是和程序开发相关性最大的部分,所有通过new方法创建出来的对象基本都是保存在Java堆中,所以这个区是最需要关注的。

堆是全局共享的,所有的线程都可以方法。

java堆的结构和GC的算法是相关的,对于分代的GC来说,堆也是分代的。

eden:伊甸园,对象出生的地方,

s0和s1:复制算法

tenured:存在时间比较久的对象会移动到老年代里面

2.4 java栈:

与堆完全不一样,栈是线程私有的。栈是由一系列的帧组成,java栈也叫帧栈。帧保存一个方法的局部变量、操作数栈以及常量指针。每一次方法调用都会创建一个帧,并压入栈。

局部变量表:包含函数的参数和局部变量

静态方法的栈内容按照从右向左的顺序压入,每个槽位占32个字节,如果变量类型超过32位,比如long,就需要占用两个槽位。

实例方法的栈内容是类似的,区别是在最后会将指向本对象的引用压入栈。函数的调用组成帧栈。

操作数栈:

java中没有寄存器,所有的参数传递都是使用操作数栈。

public static int sub(int a, int b) {
    int c=0;
    c=a-b;
    return c;
}

用javap -c查看反编译后的字节码如下:

public static int sub(int, int);
Code:
0: iconst_0 //将0压到操作数栈
1: istore_2 //弹出int,存放于局部变量2©
2: iload_0 //把局部变量0(a)压栈
3: iload_1 //把局部变量1(b)压栈
4: isub //弹出两个变量,计算差值,结果压栈
5: istore_2 // 弹出结果,放于局部变量2©
6: iload_2 //局部变量2©压栈
7: ireturn //将c的值返回

栈上分配:

在堆上分配内存空间,每次需要手工进行清理,如果是栈上分配内空间,函数调用完成后会自动进行清理。小对象(一般几十个bytes),在没有逃逸(指创建的对象还可以在其他线程中调用)的情况下,可以直接分配在栈上,对于直接分配在栈上的对象,可以自动回收,减轻GC压力。大对象或者逃逸对象无法在栈上分配。下面是一个例子:

public   class  AppMain     
 //运行时, jvm 把appmain的信息都放入方法区 
{ 
    public   static   void  main(String[] args)  
//main 方法本身放入方法区。 
    { 
     Sample test1 = new  Sample( " 测试1 " );  
      //test1是引用,所以放到栈区里, Sample是自定义对象应该放到堆里面 
     Sample test2 = new  Sample( " 测试2 " );
     test1.printName(); 
     test2.printName(); 
    }
}

public   class  Sample       
   //运行时, jvm 把appmain的信息都放入方法区 
{ 
    private  name;     
 //new Sample实例后, name 引用放入栈区里,  name 对象放入堆里 
    public  Sample(String name)  { 
        this .name = name; 
    } 
    //print方法本身放入 方法区里。
    public  void  printName()    { 
        System.out.println(name); 
    } 
}
    

jvm在调用main函数的时候会启动一个主线程,在该栈区中会保存局部变量test1和test2的引用,而test1和test2的实例保存在java堆中,类AppMain和Sample的信息,包括方法名,成员变量名则都放在方法区

三、内存模型:

每一个线程都有一个工作内存和主存(java堆)独立,工作内存中存放主存中变量的值拷贝。两块内存之间存在着变量的复制:

1.当数据从主内存复制到工作内存中是,主内存执行read操作,工作内存执行load操作;

2.当数据从工作内存复制到主内存中,工作内存执行store操作,主内存执行相应的write操作。

上面的每一个操作都是原子的,即执行期间不会被中断,要么成功,要么失败。对于普通变量,一个线程中更新的值,不会马上反应在其他变量中,主要由于存在主内存和工作内存之间的数据复制的延时。如果需要在其他线程中立即可见,需要使用volatile关键字。使用该关键字后数据会立即放在主存中,这样其他线程就立即可见了。volatile不能够替代锁,java在使用锁的时候进行了很多优化,而volatile不一定比锁性能好,使用voltatile需要看语言是否满足应用。

3.1 可见性:

一个线程修改了变量,其他线程可以立即知道。

有一下三个方法:

1.volatile:加上该关键字后如果一个线程修改了变量,会立即更新主存中的数据,其他线程都会从主存中更新该变量的值。

2.synchronized:也就是做线程之间的同步,在解锁之前会将变量值写回主存。使用同步关键字后每个线程之间是顺序执行的,所以也可以保证可见性。

3.final:作用于一些常量,在初始化完成后其他线程就立即可见。

有序性:

指在一个线程之内,所有的指令或操作都是有序的,但是在线程之外,或者在另外一个线程观察该线程的行为,会发现该线程的操作都是无序的,这是由指令重排或主内存同步延时导致的。

3.2 指令重排:

对于同一线程内执行先后顺序无影响的语句,jvm会根据实际情况进行指令重排,以加快执行速度。比如a=1;b=2;这两条语句谁先执行对最终结果是没有影响的,所以在实际执行的时候是先给a赋值,还是先个b赋值是不一定的。

语句不可重排的情况有以下三种:

写后读 a=1;b=a; 在给一个变量赋值后(写)再读取该变量的值

写后写 a=1;a=2; 在给一个变量赋值(写)后,再给这个变量赋值(写)

读后写 a=b;b=1; 在读一个变量后,在给这个变量赋值(写)

编译器不考虑多线程间的语义。

指令重排的基本原则:

1.程序顺序原则:一个线程内保证语义的串行性,即该语句重排后会改变最终的结果,那么就不能够进行指令重排。

2.volatile规则:volatile变量的写一定要先发生于读。也就是jvm不会将读的语句排到写的语句前面,避免其他线程会读到不同步的数据。

3.锁规则:解锁(unlock)必然发生在随后的加锁(lock)前,也就是先加锁,然后再进行解锁。jvm不会将解锁语句重排到前面的。

4.传递性:A先于B,B先于C,那么A必然先于C,jvm不会将A语句排到C语句后面的。

5.线程的start方法先于它的每一个动作,也是jvm会将线程的start方法放在最先位置,不会将其他的代码重排到start方法之前。

6.线程的所有操作都优先于线程的终结(Thread.join())。

7.线程的中断(interrupt())先于被中断线程的代码,保证中断后的代码在中断被调用后不会执行。

8.对象的构造函数执行结束先于finalize()方法。也就是构造函数要优先执行。

四、编译和解释运行

解释运行:

读一句执行一句

编译运行(JIT):

将字节码编译成机器码,然后直接执行机器码。这种方式是在运行时进行编译,这种编译不是静态的编译,而是jvm在运行时对字节码再次进行编译,编译后性能有数量级的提升。

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

猜你喜欢

转载自blog.csdn.net/xjjdlut/article/details/105219333