jvm 线程及锁机制

2018年04月05日 08:43 | 3362次浏览 作者原创 版权保护

Java程序采用的为多线程的方式来支撑大量的并发请求处理,在现今多核的时代,多线程的机制已经得到了认可,程序在多线程的方式执行的情况下,复杂程度远高于单线程串行执行的程序,尤其是在多核或多CPU系统中,多线程执行的程序带来的最明显的问题是线程之间共同管理的资源的竞争以及线程之间的交互,JVM的线程实现取决于操作系统,因此不多进行介绍,感兴趣的话请参阅下相关的操作系统的书籍,在本章节中仅介绍JVM是如何调度线程,另外JVM中明确定义了线程之间共同管理的资源的竞争的对应机制、线程之间的交互机制,这两个机制也是本节需要讲述的,这些对于编写稳定和高效的多线程程序而言是至关重要的。

首先来看java多线程编写时典型的获取下一ID的程序:

int i=0;
public int getNextId(){
         returni++;
         
    }

编写过多线程的开发人员都很清楚,这段代码会出现并发问题,当多线程同时访问getNextId时,返回的i的值很有可能会是相同的,来看看jvm中上面的程序的执行过程是怎么样的:

1. jvm首先在main memory区给i分配一个内存存储场所,并存储其值0;

2. 线程启动后,会自动分配一片working memory区,当线程执行到return i++时,jvm中并不是简单的一个步骤就可以完成的,由于这里关注的是i++后在多线程的情况下为什么会出现相同值的现象,因此仅分析i++这个动作,i++这个动作在JVM中划分为了装载i、读取i、进行i+1操作、存储i以及写入i五个步骤才得以完成:

l 装载i

线程发起一个装载i的请求给jvm线程执行引擎,引擎接收到请求后会向main memory发起一个read i的指令。

当read i执行完毕后,过一段时间线程将会将i的值从main memory区复制到working memory区中。

l 读取i

此步负责的是从main memory中读取i。

l 进行i+1操作

此步由线程完成。

l 存储i

将i+1的值赋给i,然后存储到working memory中。

l 写入i

一段时间后i的值会写回到main memory中。

从以上的步骤描述来看,这里面最关键的问题有两点:一是working memory中的i的值和transmit memory中的i的值的同步是需要时间的;二是i++是由多个操作组成的。只要多个线程在这个时间段内同时执行了操作,那么就会出现获取的i值相同的现象了,举个简单的例子:假设线程A已执行i+1操作,但尚未执行完写入i步骤,线程B就完成了装载i的过程,那么当线程B执行完毕时,其得到的值和A就是一样的了,这也是这段代码在多线程的情况下为什么会出现获取同样ID值的原因了。

JVM把对于working memory的操作分为了use、assign、load、store、lock和unlock,对于working memory的操作的指令由线程发出,把对于main memory的操作分为了read、write、lock和unlock,对于main memory的操作的指令由线程执行引擎发出,其含义分别为:

l use

use由线程发起,需要完成的为将变量的值从working memory中复制到线程执行引擎中。

l assign

assign由线程发起,需要完成的为将变量值复制到线程的working memory中,例如a=i,这时线程就会发起一个assign动作。

l load

load由线程发起,需要完成的将main memory中read到的值复制到working memory中。

l store

store由线程发起,负责将变量的值从working memory中复制到main memory中,并等待main memory通过write动作写入此值。

l read

read由main memory发起,负责从main memory中读取变量的值。

l write

write由main memory发起,负责将working memory的值写入到main memory中。

l lock

lock动作由线程发起,但是其为同步操作main memory,给对象加上锁。

l unlock

unlock动作由线程发起,同步操作main memory,去除对象的锁。

JVM中保证以下操作是顺序的:

l 同一个线程上的操作一定是顺序执行的;

l 对于main memory上的同一个变量的操作一定是顺序执行的,也就是不可能两个请求同时读取变量值;

l 对于加了锁的main memory上的对象的操作,一定是顺序执行的,也就是两个以上加了lock的操作,同时肯定只有一个是在执行的。

为了避免这样的现象,JVM提供了synchronized关键字、volatile关键字和lock/unlock机制,采用这几种机制可避免在多线程应用中资源值出现混乱的现象。

采用synchronized关键字改造上面的代码为:

public synchronized int getNextId(){
return i++;
}

当多线程执行此段代码时,线程A执行到getNextId()方法,JVM知道这个方法上有synchronized关键字,于是在执行其他动作前首先按照对象的实例ID加上一个lock,然后才继续执行return i++,而此时如线程B并发的访问getNextId()方法,JVM观察到这个对象的实例ID上有lock,于是将线程B等待执行的队列中,只有当线程A的return i++执行完毕后,JVM才会释放对象实例ID上的lock,重新标记为unlock,这个时候在下次线程调度到线程B时,线程B才得以执行getNextId()方法,由于这个过程是串行的,因此可以保证每个线程getNextId都是不一样的值。

synchronized除了可直接写在方法上外,也可直接定义在对象上,区别仅在于JVM会根据这些情况来决定lock标记是打在什么上,lock/unlock机制的原理和synchronized相同,synchronized、lock/unlock机制都可用于保证某段代码执行的原子性,由于锁会阻塞其他线程同样需要锁的部分的执行,因此在使用锁机制时要避免死锁的产生,尤其是在多把锁的情况下,例如:

private Object a=new Object();
private Object b=new Object();
public void callAB(){
         synchronized(b){
         synchronized(a){
         // Dosomething;
}
}
}
 
public void executeAB(){
         synchronized(a){
         synchronized(b){
         // Dosomething;
         }
         }
         }

 当上面的callAB和executeAB被两个线程同时执行时,就会产生死锁的现象,导致系统挂起,这是一个使用synchronized的例子,对于直接使用lock/unlock来编写的多线程程序而言,一定要保证lock和unlock是成对出现的,并且要保证lock后程序执行完毕时一定要unlock,否则就有线程会出现锁饿死的现象。
volatile的机制有所不同,它仅用于控制线程中对象的可见性,但并不能用于保证在此对象上操作的原子性,就像上面i++的场景,即使把i定义为volatile也是没用的,对于定义为volatile的变量,线程不会将其从main memory中复制到work memory中,而是直接在main memory中进行操作,它的代价比synchronized、lock/unlock低,但用起来要非常小心,毕竟它不能保证操作的原子性。
有了synchronized、lock/unlock以及volatile后,在Java中就可以很好的控制多线程程序中资源的竞争了,但由于这三个机制对于系统的性能都是有影响的,例如会将操作由并行化的变为了串行化,因此合理的根据需求来使用这三个机制非常重要,这点在性能调优的章节中会进一步的进行描述。
线程之间除了会产生资源的竞争外,还会有交互的需求,例如最典型的连接池,连接池中通常都会有get和return两个方法,return的时候需要将连接返回到缓存列表中,并将可使用的连接数加1,而get方法在判断可使用的连接数已经到了0后,需要进入一个等待状态,当有连接返回到连接池时,应该通知下get方法,不需要再等待了,如果没有这个交互机制的话,就只能在get方法中不断的轮循判断可使用的连接数的值了,JVM提供了waittifytifyAll这样的方式来支持这类需求。
调用Objectwait方法可以让当前线程进入等待状态,只有当其他线程调用了此Object的notify或notifyAll方法时或者wait(毫秒数)到达了指定的时间后,才会被激活继续执行,notify只是随机找wait此Object的一个线程,而notifyAll则是通知wait此Object的所有线程。
当线程调用了对象的wait方法后,JVM线程执行引擎会将此线程放入一个wait sets中,并释放此对象的锁,在wait sets中的线程将不会被JVM线程执行引擎调度执行,当其他线程调用了此对象的notify方法时,会从wait sets中随机找一个等待在此对象上的线程,并将其从wait sets中删除,这样JVM线程执行引擎就可以再次调度执行此线程了,当调用的为 notifyAll方法时,则会删除wait sets上所有等待在此对象上的线程,在删除完毕后释放对象锁。

Sun JDK中还提供了多种其他方式的线程交互的支持,例如Semphore、Condition、CountDownLatch等。

JVM把线程分为几种不同的状态,并根据状态放入不同的sets中来进行调度,线程在创建完毕后进入new状态,调用了线程的start方法后线程进入Runnable状态,放入JVM的可运行线程队列中,等待获取CPU的执行权;JVM按线程优先级以及时间分片、轮循的方式来执行Runnable状态的线程,当线程进入start代码段,开始执行时,其线程状态转变为Running;线程在执行过程中如执行了sleep、wait、join,或进入了IO阻塞、锁等待时,则进入了Wait或Blocked状态,在这些状况下时线程放弃CPU的使用权,进入wait sets中或锁sets中,直到wait结束、线程被唤醒或获取到锁,在这些情况下线程都是再次进入Runnable状态;当线程执行完毕后,线程就从可运行线程队列中删除了,JVM线程的状态转变图示如下:


为了能够跟踪运行时JVM中线程的状况,Sun JDK以及开源界提供了一些不错的工具,对于运行时判断什么操作是耗系统资源的,哪些操作出现了锁竞争激烈或死锁的现象等有很大的帮助。

l kill -3 [pid]

在linux上,找出java所在的进程号,然后执行kill -3 [pid],线程的相关信息就输出到Console上了。

l jstack

jstack是Sun JDK中自带的工具,通过这个工具可以直接看到jvm中线程的运行状况,包括锁的等待、线程是否在运行等。

执行jstack [pid],线程的所有堆栈信息即输出了,类似如下:

"http-8080-10" daemon prio=10 tid=0xa949bb60 nid=0x884 waiting
for monitor entry [a765c000..a765e878]
at java.sql.DriverManager.getConnection(DriverManager.java:187)
- waiting to lock <0xaf5bcf10> (a java.lang.Class)

此行表示http-8080-10这个线程处于等锁的状态,waiting for monitor entry如果在连续几次输出线程堆栈信息都存在于同一或多个线程上时,则说明系统中有锁竞争激烈的现象、死锁或锁饿死的现象。

"http-8080-6" daemon prio=10 tid=0x0000002b031c6800 nid=0x565d in Object.wait() [0x0000000041b43000..0x0000000041b43c30]
   java.lang.Thread.State: WAITING (on object monitor)

此行表示http-8080-6的线程处于对象的wait上,等待其他线程的唤醒,这也是线程池的常见用法。

"Low Memory Detector" daemon prio=10 tid=0x0000002afff4c400 nid=0x5653 runnable [0x0000000000000000..0x0000000000000000]

   java.lang.Thread.State: RUNNABLE

此行表示Low Memory Detector的线程处于runnable状态,等待获取CPU的使用权。

l JConsole

JConsole是SUN JDK中自带的一个工具,位于JDK的bin目录下,其可用于图形化的跟踪查看运行时系统中线程的状况(运行状态、锁等待、堆栈、检测死锁等),效果图示如下:



l ThreadXMBean

ThreadXMBean是Sun JDK中自带的一个可直接访问的MBean,该MBean的名称为java.lang.Threading,通过JConsole访问此MBean可看到如下信息:



也可以编写程序直接访问此MBean,通过这个MBean可以获取到jvm中所有的线程、线程的运行状态、每个线程耗费的CPU的时间、处于等待Object锁的线程数等。

另外JConsole的demo中还附带了一个JTop的程序,由于可通过它更为形象的跟踪系

统中线程消耗的CPU的时间,因此在此也提及下,在运行JConsole时通过指定-pluginpath [JTop.jar](默认情况下,JTop.jar位于demo\management\JTop下),在打开JConsole后,Tab页上就会增加一个JTop的tab,效果图示如下:

l TDA

TDA是开源界一个不错的用于分析线程堆栈信息的图形化工具,其网站为:http://tda.dev.java.net/,执行TDA后,直接将包含线程堆栈信息的文件导入即可,效果图示如下:

JVM

通过此工具可比较方便的分析线程堆栈中等待锁的线程、各种状态的线程以及运行时间

较长的线程等。

除了上面提及的这些工具外,在业界还有不少商业的可用于分析JAVA程序的线程状况的工具,例如大名鼎鼎的JProfiler。

本章从JVM将字节码文件通过ClassLoader加载到JVM、Class中方法的执行、Class中对象的内存分配和回收到JVM的线程以及锁机制,涵盖了一个典型的Java Class从加载到执行完毕的整个过程涉及到的JVM的知识体系,虽然这并不是JVM的全部,例如还有Java代码是如何编译成字节码的、Java类文件的格式等,但称得上是JVM的精华知识所在,在看完了本章的内容后再看看下面的一段简单的代码:

public class InsideJVM{
    public static final String MESSAGE=”Hello”;
    private IA a=new A();
    public static void main(String[] args){
B b=new B();
a.call();
b.execute();
System.out.println(“Hello again”);
}
}

思考下这段代码在编译成字节码后,整个执行过程是如何完成的,例如编译成字节码后大概是怎么样的、加载到JVM中是个怎么样的过程、涉及到的对象的内存是如何分配的、对象的方法是如何执行的,如果对这些都大概的清楚了的话,也就可以说对JVM的关键机制有一定的掌握了。

JVM对于Java程序开发人员而言有些过于底层,而JDK中的常用的包、类,例如集合包、网络包、并发包、ArrayList、HashMap等,则是开发人员每天都需要使用的,JDK中可用的类非常的多,如何在这众多的类中挑选出符合需求的类呢,这就要求开发人员对JDK中常用的包、类的实现有一定的掌握.



小说《我是全球混乱的源头》
此文章本站原创,地址 https://www.vxzsk.com/754.html   转载请注明出处!谢谢!

感觉本站内容不错,读后有收获?小额赞助,鼓励网站分享出更好的教程


上一篇:jquery 简介 下一篇:javascript 异常处理
^