Java的多线程机制系列:(一)总述及基础概念

前言

这一系列多线程的文章,一方面是个人对Java现有的多线程机制的学习和记录,另一方面是希望能给不熟悉Java多线程机制、或有一定基础但理解还不够深的读者一个比较全面的介绍,旨在使读者对Java的多线程有一个递增、全面和较深刻的理解,所以在第一部分就集中介绍一些概念和原理,表面看来这些对多线程的使用没有太多关系,但理解这些概念/原理对理解多线程是至关重要的,因为Java的多线程并非是完全独自实现的,它依赖于操作系统命令、CPU机制,并且随着这些基础软硬件的发展而发展,所以请有意向对多线程全面理解的读者,请耐心地一篇一篇地看完,我尽量在介绍的过程中给予足够而又简单的介绍,如果不能理解,请查阅操作系统及CPU方面的资料。

本系列文章的资料,都来源于官方文档以及相关人士/网友的论文、文章,是个人的学习总结,是对多线程机制的理解,不存在原创算法/策略思想,毕竟原创/策略思想只有HotSpot作者及从事相关研究的大师才能提出。网络本身是个开放、免费的环境,如果本系列文章引用了其他作者的文字,还请作者多多理解,因为本身他们的文字大多也是来源于官方资料的。

总述

在JDK5之前,Java的多线程(包括它的性能)一直是个软肋,只有synchronized、Thread.sleep()、Object.wait/notify这样有限的方法,而synchronized的效率还特别地低(为什么低,在后面的“内核态与用户态”一节有详细叙述),开销比较大。JDK5相对于前面版本是重大改进,不仅在Java语法上有了很多改进(包括泛型、装箱、for循环、变参等),在多线程上有了彻底的提高,其引进了并发编程大师Doug Lea的java.util.concurrent包(后面简称J.U.C),支持了现代CPU的CAS原语,不仅在性能上有了很大提升,在*度上也有了更多的选择,此时J.U.C的效率在高并发环境下的效率远优于synchronized。但JDK6(Mustang 野马)中对synchronized的内在机制做了大量显著的优化,加入了CAS的概念以及偏向锁、轻量级锁,使得synchronized的效率与J.U.C不相上下,并且官方说后面该关键字还有继续优化的空间,所以在现在JDK7的时代,synchronized已经成为一般情况下的首选,在某些特殊场景——如可中断的锁、条件锁、等待获得锁一段时间如果失败则停止——下,J.U.C是适用的,所以对于多线程研究来说,了解其原理以及各自的适用场景是必要的。

这里必须要指出的是,JDK5之前的版本对多线程的支持一直不佳,这并非是Sun的原因。Java是1995年诞生的(由Oak语言改名为Java,Oak语言当时是打算在电子消费品和嵌入式上建立统一平台,没想到后面却发展成为主流的企业级应用语言),96年JDK1.0发布,2002年JDK1.4发布,这是java真正走向成熟的一个版本,但是当时的PC并不如今天这样的普及,硬件整体是以单CPU和单核为主,也就是说既不普遍存在如今这样高并发的使用场景、也不存在硬件多CPU、多核这样的支持,而随着时代的发展,高并发场景越来越多,多CPU由于是多核PC越来越普遍,相应的操作系统的指令集也跟随这种形式出现了如CAS这样的原语(什么是CAS后面会重点阐述),也就是说,正是这些应用场景和基础设施都具备了,Java这样的高级语言自然也就需要有更多更好的对多线程的支持。技术总是跟随着时代的发展而发展,又反过来推动着时代的前进,按照马克思主义来说,是相辅相成。这也就是JDK5对多线程做了大量改进的历史背景,而到了如今2013年,JDK8即将正式发布,语言原生的多线程机制也未必能满足时代要求了,于是很多天生适合多线程环境的(如ErLang这样无状态的函数式编程语言)开始提供了Java版本,使得Java成为一个语言平台——多线程、大计算的任务更多的是委派给适合多线程的语言来做,而Java专注于后台业务处理,这也就是语言发展的脉络。

基本概念

1.线程

线程是依附于进程的,进程是分配资源的最小单位,一个进程可以生成多个线程,这些线程拥有共享的进程资源。就每个线程而言,只有很少的独有资源,如控制线程运行的线程控制块,保留局部变量和少数参数的栈空间等。线程有就绪、阻塞和运行三种状态,并可以在这之间切换。也正因为多个线程会共享进程资源,所以当它们对同一个共享变量/对象进行操作的时候,线程的冲突和不一致性就产生了。线程这个概念在这里就不详述了,如果还不是很清楚地,可以查些相关资料。

多线程并发环境下,本质上要解决地是这两个问题:

  • 线程之间如何通信。
  • 线程之间如何同步。

概括起来说就是:线程之间如何正确通信。这是本系列所需要讲的主题。虽然说的是在Java层面如何保证,但会涉及到java虚拟机、Java内存模型,以及Java这样的高级语言最终是要映射到CPU来执行(关键原因是如今的CPU有缓存、并且是多核的),所以本系列也会涉及一定的操作系统/硬件方面的知识,虽然有些难懂,但对于深刻把握多线程是至关重要的,所以需要多花一些时间。

2.锁

当多个线程对同一个共享变量/对象进行操作,即使是最简单的操作,如i++,在处理上实际也涉及到读取、自增、赋值这三个操作,也就是说这中间存在时间差,导致多个线程没有按照如程序编写者所设想的去顺序执行,出现错位,从而导致最终结果与预期不一致。

Java中的多线程同步是通过锁的概念来体现。锁不是一个对象、不是一个具体的东西,而是一种机制的名称。锁机制需要保证如下两种特性:

  • 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。
  • 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。

上面说的“持有某个对象锁”这不太好理解,有些抽象。程序又不是人,怎么能持有呢?什么样算持有呢?看完后文的同步机制内容,就会有一定理解,这里暂且可以把它理解为:对对象的占有权。持有某个对象锁,就是告诉大家,这个对象现在归我所用,在我没释放之前,别人不能占用这个对象。网上大多文章说把锁理解为房间钥匙,拿到锁的线程等于拿到房间钥匙,可以进房间(也就是执行需同步的代码块),而别的线程就不能拿这把钥匙了,就是这个道理。

线程持有对象锁(钥匙)的目的,并不是仅仅拿着,而是表明拥有了代码段的执行权(拿钥匙不是目的,进房间才是目的),别的线程没拿到对象锁,也就不能执行拿到锁和释放锁之间的代码(如下例中的val++就是上面所说的“代码段”,也许表述不是那么清晰,但相信大家还是好理解的)

public synchronized void synMethod(){
        val++;
    }

.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }

 

.csharpcode, .csharpcode pre
{
font-size: small;
color: black;
font-family: consolas, "Courier New", courier, monospace;
background-color: #ffffff;
/*white-space: pre;*/
}
.csharpcode pre { margin: 0em; }
.csharpcode .rem { color: #008000; }
.csharpcode .kwrd { color: #0000ff; }
.csharpcode .str { color: #006080; }
.csharpcode .op { color: #0000c0; }
.csharpcode .preproc { color: #cc6633; }
.csharpcode .asp { background-color: #ffff00; }
.csharpcode .html { color: #800000; }
.csharpcode .attr { color: #ff0000; }
.csharpcode .alt
{
background-color: #f4f4f4;
width: 100%;
margin: 0em;
}
.csharpcode .lnum { color: #606060; }

互斥性和可见性,这是锁机制的两个重要概念,在后面的文章中会多次提到,是理解Java多线程机制的基础。

3.挂起、休眠、阻塞与非阻塞

这四个名次,在多线程里是会频繁提到的,所以有必要对它们解释一下。

挂起(Suspend):当线程被挂起的时候,其会失去CPU的使用时间,直到被其他线程(用户线程或调度线程)唤醒。

休眠(Sleep):同样是会失去CPU的使用时间,但是在过了指定的休眠时间之后,它会自动激活,无需唤醒(整个唤醒表面看是自动的,但实际上也得有守护线程去唤醒,只是不需编程者手动干预)。

阻塞(Block):在线程执行时,所需要的资源不能得到,则线程被挂起,直到满足可操作的条件。

非阻塞(Block):在线程执行时,所需要的资源不能得到,则线程不是被挂起等待,而是继续执行其余事情,待条件满足了之后,收到了通知(同样是守护线程去做)再执行。

挂起和休眠是独立的操作系统的概念,而阻塞与非阻塞则是在资源不能得到时的两种处理方式,不限于操作系统,当资源申请不到时,要么挂起线程等待、要么继续执行其他操作,资源被满足后再通知该线程重新请求。显然非阻塞的效率要高于阻塞,相应的实现的复杂度也要高一些。

在Java中显式的挂起原先是通过Thread的suspend方法来体现,现在此概念已经消失,原因是suspend/resume方法已经被废弃,它们容易产生死锁,在suspend方法的注释里有这么一段话:当suspend的线程持有某个对象锁,而resume它的线程又正好需要使用此锁的时候,死锁就产生了。所以在现在的JDK版本中,挂起是JVM的系统行为,程序员无需干涉。休眠的过程中也不会释放锁,但它一定会在某个时间后被唤醒,所以不会死锁。现在我们所说的挂起,往往并非指编写者的程序里主动挂起,而是由操作系统的线程调度器去控制。所以,我们常常说的“线程在申请锁失败后会被挂起、然后等待调度”这样有一定歧义,因为这里的“挂起”是操作系统级别的挂起,其实是在申请资源失败时的阻塞,和Java中的线程的挂起(可能已经获得锁,也可能没有锁,总之和锁无关)不是一个概念,很容易混淆,所以在后文中说的挂起,一般指的是操作系统的操作,而不是Thread中的suspend()。

相应地我们有必要提下java.lang.Object的wait/notify,这两个方法同样是等待/通知,但它们的前提是已经获得了锁,且在wait(等待)期间会释放锁。在wait方法的注释里明确提到:线程要调用wait方法,必须先获得该对象的锁,在调用wait之后,当前线程释放该对象锁并进入休眠(这里到底是进入休眠还是挂起?文档没有细说,从该方法能指定等待时间来看,我觉得更可能是休眠,没有指定等待时间的,则可能是挂起,不管如何,在休眠/挂起之前,JVM都会从当前线程中把该对象的锁释放掉),只有以下几种情况下会被唤醒:其他线程调用了该对象的notify或notifyAll、当前线程被中断、调用wait时指定的时间已到。

4.内核态与用户态

这是两个操作系统的概念,但理解它们对我们理解Java的线程机制有着一定帮助。

有一些系统级的调用,比如清除时钟、创建进程等这些系统指令,如果这些底层系统级指令能够被应用程序任意访问的话,那么后果是危险的,系统随时可能崩溃,所以CPU将所执行的指令设置为多个特权级别,在硬件执行每条指令时都会校验指令的特权,比如Intel x86架构的CPU将特权分为0-3四个特权级,0级的权限最高,3权限最低。

而操作系统根据这系统调用的安全性分为两种:内核态和用户态。内核态执行的指令的特权是0,用户态执行的指令的特权是3。当一个任务(进程)执行系统调用而进入内核指令执行时,我们就说进程处于内核运行态(或简称为内核态)。当任务(进程)执行自己的代码的时候,就处于用户态。这就像我们Java的class,有很多的private方法,但对外公开的只有少量public方法一样,这些private方法只有class本身可以调用的,不允许外界调用,否则会产生意料不到的问题。

那明白了内核态和用户态的概念之后,我们来看在这两种状态之间切换会造成什么样的效率影响(这里所说的切换就是执行一段用户代码、再执行一段内核代码、再执行一段用户代码这样的交替行为,说交替执行更合适,说切换有些混淆)。在执行系统级调用时,需要将变量传递进去、可能要拷贝、计数、保存一些上下文信息,然后内核态执行完成之后需要再将参数传递到用户进程中去,这个切换的代价相对来说是比较大的,所以应该是尽量避免频繁地在内核态和用户态之间切换。

好了,那操作系统的这两种形态和我们的线程主题有什么关系呢?这里是关键。Java并没有自己的线程模型,而是使用了操作系统的原生线程!如果要实现自己的线程模型,那么有些问题就特别复杂,难以解决,比如如何处理阻塞、如何在多CPU之间合理地分配线程、如何锁定,包括创建、销毁线程这些,都需要Java自己来做,在JDK1.2之前Java曾经使用过自己实现的线程模型,后来放弃了,转向使用操作系统的线程模型,因此创建、销毁、调度、阻塞等这些事都交由操作系统来做,而线程方面的事在操作系统来说属于系统级的调用,需要在内核态完成,所以如果频繁地执行线程挂起、调度,就会频繁造成在内核态和用户态之间切换,影响效率(当然,操作系统的线程操作是不允许外界(包括Java虚拟机)直接访问的,而是开放了叫“轻量级进程”的接口供外界使用,其与内核线程在Window和Linux上是一对一的关系,这里不多叙述)。

我们说JDK5之前的synchronized效率低下,是因为在阻塞时线程就会被挂起、然后等待重新调度,而线程操作属于内核态,这频繁的挂起、调度使得操作系统频繁处于内核态和用户态的转换,造成频繁的变量传递、上下文保存等,从而性能较低。

如果需要更多地了解操作系统的用户态和内核态的用处,以及操作系统的线程模型,请查阅相关资料。

上一篇:Python编程从入门到实践笔记——变量和简单数据类型


下一篇:Java私有构造器