《编程机制探析》第九章 线程

《编程机制探析》第九章 线程

本章开始讲述线程(Thread)的相关知识。线程(Thread)是计算机编程中的非常重要的概念,其概念与进程(Process)类似,都代表着内存中一份正在执行的程序。两者的共同点在于,两者都有自己的运行栈。两者之间的区别在于,进程拥有一份独立的进程空间,而线程没有。线程只能依附于进程存在。一个进程下面的多个线程,只能共享同一份进程空间。因此,线程和进程的主要区别,就在于共享资源方面。除此之外,两者的运行、调度,几乎都是一样的。
由于线程少了一份独立资源,比进程更加轻量,因此,程序员在编程时,更多地使用线程,而不是进程。只有在某些不支持线程的操作系统中,还有一些特殊要求的情况下,程序员才会使用进程。
线程本身的概念没什么可说的,当我们提到线程的时候,我们通常谈论的都是线程同步(Thread Synchronization)问题。
线程同步问题,可以说是关于线程的最重要的问题。我们甚至可以这样说,线程同步问题,是关于线程的唯一重要的问题。因此,关于线程的话题,基本上就是关于线程同步的话题。当我们耳朵里听到线程这个词,心里面就会自动把这个词补充为“线程同步”这个词组。
那么,线程同步,到底是怎么回事呢?前面说了,线程没有自己的资源,它只能和父进程,以及其他线程一起共享同一份进程资源。这就不可避免地设计到资源竞争问题。线程有可能和其他线程在同一个时间段访问一些共同的资源,比如,内存,文件,数据库等。当多个线程同时读写同一份共享资源的时候,可能会引起冲突。这时候,我们需要引入线程“同步”机制,即各位线程之间要有个先来后到,不能一窝蜂挤上去抢作一团。
同步这个词是从英文synchronize(使同时发生)翻译过来的。我也不明白为什么要用这个很容易引起误解的词。既然大家都这么用,咱们也就只好这么将就。
线程同步的真实意思和字面意思恰好相反。线程同步的真实意思,其实是“排队”:几个线程之间要排队,一个一个对共享资源进行操作,而不是同时进行操作。
因此,关于线程同步,需要牢牢记住的第一点是:线程同步就是线程排队。同步就是排队。线程同步的目的就是避免线程“同步”执行。这可真是个无聊的绕口令。
关于线程同步,需要牢牢记住的第二点是 “共享”这两个字。只有共享资源的读写访问才需要同步。如果不是共享资源,那么就根本没有同步的必要。
关于线程同步,需要牢牢记住的第三点是,只有“变量”才需要同步访问。如果共享的资源是固定不变的,那么就相当于“常量”,线程同时读取常量也不需要同步。至少一个线程修改共享资源,这样的情况下,线程之间就需要同步。
关于线程同步,需要牢牢记住的第四点是:多个线程访问共享资源的代码有可能是同一份代码,也有可能是不同的代码;无论是否执行同一份代码,只要这些线程的代码访问同一份可变的共享资源,这些线程之间就需要同步。
为了加深理解,下面举几个例子。
有两个采购员,他们的工作内容是相同的,都是遵循如下的步骤:
(1)到市场上去,寻找并购买有潜力的样品。
(2)回到公司,写报告。
这两个人的工作内容虽然一样,他们都需要购买样品,他们可能买到同样种类的样品,但是他们绝对不会购买到同一件样品,他们之间没有任何共享资源。所以,他们可以各自进行自己的工作,互不干扰。
这两个采购员就相当于两个线程;两个采购员遵循相同的工作步骤,相当于这两个线程执行同一段代码。
下面给这两个采购员增加一个工作步骤。采购员需要根据公司的“布告栏”上面公布的信息,安排自己的工作计划。
这两个采购员有可能同时走到布告栏的前面,同时观看布告栏上的信息。这一点问题都没有。因为布告栏是只读的,这两个采购员谁都不会去修改布告栏上写的信息。
下面增加一个角色。一个办公室行政人员这个时候,也走到了布告栏前面,准备修改布告栏上的信息。
如果行政人员先到达布告栏,并且正在修改布告栏的内容。两个采购员这个时候,恰好也到了。这两个采购员就必须等待行政人员完成修改之后,才能观看修改后的信息。
如果行政人员到达的时候,两个采购员已经在观看布告栏了。那么行政人员需要等待两个采购员把当前信息记录下来之后,才能够写上新的信息。
上述这两种情况,行政人员和采购员对布告栏的访问就需要进行同步。因为其中一个线程(行政人员)修改了共享资源(布告栏)。而且我们可以看到,行政人员的工作流程和采购员的工作流程(执行代码)完全不同,但是由于他们访问了同一份可变共享资源(布告栏),所以他们之间需要同步。
前面讲了为什么要线程同步,下面我们就来看如何才能线程同步。
线程同步的基本实现思路还是比较容易理解的。我们可以给共享资源加一把锁,这把锁只有一把钥匙。哪个线程获取了这把钥匙,才有权利访问该共享资源。
生活中,我们也可能会遇到这样的例子。一些超市的外面提供了一些自动储物箱。每个储物箱都有一把锁,一把钥匙。人们可以使用那些带有钥匙的储物箱,把东西放到储物箱里面,把储物箱锁上,然后把钥匙拿走。这样,该储物箱就被锁住了,其他人不能再访问这个储物箱。(当然,真实的储物箱钥匙是可以被人拿走复制的,所以不要把贵重物品放在超市的储物箱里面。于是很多超市都采用了电子密码锁。)
线程同步锁这个模型看起来很直观。但是,还有一个严峻的问题没有解决,这个同步锁应该加在哪里?
当然是加在共享资源上了。反应快的读者一定会抢先回答。
没错,如果可能,我们当然尽量把同步锁加在共享资源上。一些比较完善的共享资源,比如,文件系统,数据库系统等,自身都提供了比较完善的同步锁机制。我们不用另外给这些资源加锁,这些资源自己就有锁。
但是,我们在代码中访问的共享资源是代码中的共享数据呢?
读者一定又会说了。那有什么关系,我们也可以在那些数据上加锁呀。
那么,我们现在来考虑,这个锁,应该怎么加?
这还不简单。有人说了。在访问所有共享变量之前,都检查一下该共享变量的同步锁就好了。
那么,另一个问题就出现了。我们总得有个地方存放同步锁。那个同步锁放在哪里,放在共享变量的内部空间里面吗?还是放在共享变量的外面,比如在内存中的某一个地方?
显然,放在变量的内部空间是不合理的,应该放在外面的某个对应空间里。
这种设计方案看起来是可行的。事实上,确实存在这样的实现方案。比如,在Java语言中,有一个叫做volatile的关键字,就是加在变量名前面的。
volatile关键字可以加在任何类型的变量前面,可以加在Object Reference类型(即对象)的前面,也可以加在简单类型(比如char、int、float、long、double等)的前面。
当变量是Object Reference类型(对象)的时候,在前面加volatile是没有意义的。因为对象的赋值只是一种Object Reference(内存地址)的赋值,并不引起对象内部结构数据的任何变化。而且,Object Reference的赋值,通常都是不需要同步的原子操作。
当变量是简单类型(比如char、int、float、long、double等)的时候,volatile能够工作得很好。当程序对这些变量进行存取读写的时候,Java虚拟机会对这些简单变量进行自动同步。
一般来讲,volatile关键字只对long、double等长类型才有意义。因为,短类型的操作基本上都是原子操作。而原子操作是一次操作就完成的,不需要分多次操作,因此也不需要进行同步处理。
现在,我们考虑复杂的情况。当共享数据是复合结构的时候,比如,当共享数据是一个包含关系复杂的对象的时候,我们该如何对这样的结构进行加锁?是总体加一个锁?还是为每一个属性都加锁?加锁的深度和层次又该如何控制?当对象关系进一步复杂化,又该如何处理?这种锁的控制相当复杂,几乎相当于用户系统中的权限管理了,早已经超出了虚拟机的任务范畴。即使虚拟机能够实现这么一套复杂的锁机制,那也是吃力不讨好的。首先,这样的锁机制很可能是大而无当的,大多数情况根本用不上。其次,这样的锁机制也不可能包括所有复杂的情况,总有一些特殊的需求无法满足。
那么,同步锁又该如何应对复杂数据结构的复杂情况呢?编程语言的设计者很聪明。他们把这个问题交给程序员自己解决。他们的设计思路很简单——把同步锁加在代码段上,确切的说,是把同步锁加在“访问共享资源的代码段”上。代码是程序员自己写的,可以写得非常简单,也可以写得非常复杂,可以应对各种简单或者复杂的需求。问题就这样解决了。
因此,我们一定要注意这一点,同步锁是加在代码段上的。有读者说了,不是还有个volatile关键字可以加在变量名前面吗?
没错。但是,在实际的应用中,volatile几乎没有用武之地。至少我在编程的过程中,从来就没有用过这个关键字。所以,我们可以有意地忽略volatile这个关键字。我们只需要学习和思考“同步锁加在代码段上”的线程同步模型。
现在,我们需要思考的问题是,代码段上应该如何加锁?应该加怎样的锁?
这个问题是重点中的重点。这是我们尤其要注意的问题:访问同一份共享资源的不同代码段,应该加上同一个同步锁;如果加的是不同的同步锁,那么根本就起不到同步的作用,没有任何意义。这就是说,同步锁本身也一定是多个线程之间的共享对象。
不同编程语言的同步锁概念和模型基本上都是一样的,只是用法上有些细微的差别。Java语言的同步锁机制并不算是最完善的,但是,却足够简单。而且,程序员可以通过Java语言本身的语法层次上的同步锁机制构造出自己需要的更复杂的同步锁模型。在我看来,Java语言的同步锁机制还是做得很不错。
Java语言中的同步锁的关键字只有一个,那就是synchronized。整个语法形式表现为
synchronized(同步锁) {
// 访问共享资源、需要同步的代码段
}
其中“同步锁”是什么呢?就是一个Object Reference。任何一个Java对象,或者说,任何一个Object Reference,都可以对应一个同步锁。换句话说,任何一个Java对象实例,都可以被synchronized关键字包裹起来,承担起同步锁的任务。
为什么这么设计呢?是否有什么深刻的道理?在我看来,没有任何深刻的道理。这么设计,只是为了方便起见。反正每个Object Reference都是唯一的内存地址,而同步锁也要求是唯一的,就顺手这么设计了。
这里尤其要注意的就是,同步锁本身一定要是共享的对象。我们下面来看一段错误代码。
… f1() {

Object lock1 = new Object(); // 产生一个同步锁

synchronized(lock1){
// 代码段 A
// 访问共享资源 resource1
// 需要同步
}
}
上面这段代码没有任何意义。因为那个同步锁是在函数体内部产生的局部变量。每个线程调用这段代码的时候,都会产生一个新的同步锁。那么多个线程之间,使用的是不同的同步锁。根本达不到同步的目的。
同步代码一定要写成如下的形式,才有意义。

public static final Object lock1 = new Object();

… f1() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 A
// 访问共享资源 resource1
// 需要同步
}
你不一定要把同步锁声明为static或者public,但是你一定要保证相关的同步代码之间,一定要使用同一个同步锁。
再次重申。在Java里面,同步锁的概念就是这样的。任何一个Object Reference都可以作为同步锁。我们可以把Object Reference理解为对象在内存分配系统中的内存地址。因此,要保证同步代码段之间使用的是同一个同步锁,我们就要保证这些同步代码段的synchronized关键字使用的是同一个Object Reference,同一个内存地址。这也是为什么我在前面的代码中声明lock1的时候,使用了final关键字,这就是为了保证lock1的Object Reference在整个系统运行过程中都保持不变。
现在,我们来思考一个问题。同步锁应该加在谁的代码段上?所有访问共享资源的代码都需要加锁吗?是这样的。不过,我们可以遵循这样一种设计原则:尽量把某一个共享资源的访问方法都集中到一个类中,我们只对这个类中的代码进行加锁;其他代码要访问这个共享资源,都需要通过这个类;这种设计原则就可以有效地避免同步锁代码的扩散。
一些求知欲强的读者可能想要继续深入了解synchronzied(同步锁)的实际运行机制。Java虚拟机规范中(你可以在google用“JVM Spec”等关键字进行搜索),有对synchronized关键字的详细解释。synchronized会编译成 monitor enter, … monitor exit之类的指令对。Monitor就是实际上的同步锁。每一个Object Reference在概念上都对应一个monitor。
这种同步锁机制的设计,已经成为一种设计模式,叫做Monitor Object。我们继续看几个例子,加深对Monitor Object设计模式的理解。
public static final Object lock1 = new Object();

… f1() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 A
// 访问共享资源 resource1
// 需要同步
}
}

… f2() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 B
// 访问共享资源 resource1
// 需要同步
}
}
上述的代码中,代码段A和代码段B就是同步的。因为它们使用的是同一个同步锁lock1。
如果有10个线程同时执行代码段A,同时还有20个线程同时执行代码段B,那么这30个线程之间都是要进行同步的。
这30个线程都要竞争一个同步锁lock1。同一时刻,只有一个线程能够获得lock1的所有权,只有一个线程可以执行代码段A或者代码段B。其他竞争失败的线程只能暂停运行,进入到该同步锁的就绪(Ready)队列。
每一个同步锁下面都挂了几个线程队列,包括就绪(Ready)队列,待召(Waiting)队列等。比如,lock1对应的就绪队列就可以叫做lock1 - ready queue。每个队列里面都可能有多个暂停运行的线程。
注意,竞争同步锁失败的线程进入的是该同步锁的就绪(Ready)队列,而不是后面要讲述的待召队列(Waiting Queue,也可以翻译为等待队列)。就绪队列里面的线程总是时刻准备着竞争同步锁,时刻准备着运行。而待召队列里面的线程则只能一直等待,直到等到某个信号的通知之后,才能够转移到就绪队列中,准备运行。
成功获取同步锁的线程,执行完同步代码段之后,会释放同步锁。该同步锁的就绪队列中的其他线程就继续下一轮同步锁的竞争。成功者就可以继续运行,失败者还是要乖乖地待在就绪队列中。
因此,线程同步是非常耗费资源的一种操作。我们要尽量控制线程同步的代码段范围。同步的代码段范围越小越好。我们用一个名词“同步粒度”来表示同步代码段的范围。
在Java语言里面,synchronized关键字除了上述的用法,还可以加在方法名的前面修饰整个方法。比如。
… synchronized … f1() {
// f1 代码段
}

这段代码就等价于
… f1() {
synchronized(this){ // 同步锁就是对象本身
// f1 代码段
}
}

同样的原则适用于静态(static)函数
比如。
… static synchronized … f1() {
// f1 代码段
}

这段代码就等价于
…static … f1() {
synchronized(Class.forName(…)){ // 同步锁是类型本身。虚拟机中独一份。
// f1 代码段
}
}

但是,我们要尽量避免这种直接把synchronized加在函数定义上的偷懒做法。因为我们要控制同步粒度。同步的代码段越小越好。synchronized控制的范围越小越好。
我们不仅要在缩小同步代码段的长度上下功夫,我们同时还要注意细分同步锁。比如,下面的代码:
public static final Object lock1 = new Object();

… f1() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 A
// 访问共享资源 resource1
// 需要同步
}
}

… f2() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 B
// 访问共享资源 resource1
// 需要同步
}
}

… f3() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 C
// 访问共享资源 resource2
// 需要同步
}
}

… f4() {

synchronized(lock1){ // lock1 是公用同步锁
// 代码段 D
// 访问共享资源 resource2
// 需要同步
}
}
上述的4段同步代码,使用同一个同步锁lock1。所有调用4段代码中任何一段代码的线程,都需要竞争同一个同步锁lock1。
我们仔细分析一下,发现这是没有必要的。
因为f1()的代码段A和f2()的代码段B访问的共享资源是resource1,f3()的代码段C和f4()的代码段D访问的共享资源是resource2,它们没有必要都竞争同一个同步锁lock1。我们可以增加一个同步锁lock2。f3()和f4()的代码可以修改为:
public static final Object lock2 = new Object();
… f3() {
synchronized(lock2){ // lock2 是公用同步锁
// 代码段 C
// 访问共享资源 resource2
// 需要同步
}
}

… f4() {

synchronized(lock2){ // lock2 是公用同步锁
// 代码段 D
// 访问共享资源 resource2
// 需要同步
}
}
这样,f1()和f2()就会竞争lock1,而f3()和f4()就会竞争lock2。这样,分开来分别竞争两个锁,就可以大大较少同步锁竞争的概率,从而减少系统的开销。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值