您的位置:首页 > 编程语言 > Java开发

Java之美[从菜鸟到高手演变]之线程同步的引入

2013-03-29 00:58 218 查看
转自:http://www.51weixue.com/thread-103-1-1.html

从上一贴(Java之美[从菜鸟到高手演变]之多线程简介)中,我们了解了关于多线程开发的一些概念,本贴我们将通过具体事例引入线程同步问题,后续会不断的提出线程同步的方法。我们知道,采用多线程可以合理利用CPU的空闲资源,从而在不增加硬件的情况下,提高程序的性能!听上去很有诱惑力,可是为什么我们的项目不都采用多线程开发呢?原因如下:

1、多线程开发会带来线程安全问题。多个线程同时对一个对象进行读写操作,必然会带来数据不一致的问题。2、在单核的情况下,经过了线程同步的多线程应用,未必比单线程应用性能要高,因为维护多线程所耗的资源并不少。(现在的单核环境已经不多了,不过此处为了说明并不是所有地方都用多线程好)。3、编写正确的多线程应用非常不易。4、只有在需要资源共享的情况下,才会用到多线程。想要解决第一个问题,我们需要用到线程同步,这也是做多线程开发的最难的一点!本章我将介绍一些线程安全的问题,逐步引入线程同步的方法。

我们来看个小例子:

public class Generator {

private int value = 1;

public int getValue(){

return value++;

}

}

复制代码
getValue方法的目的是每次调用,生成不同的值,但是我们来看看这种情况:如果现在又多个线程同时调用,会发生什么?我们假设有两个线程:A、B。对于value++来说,相当于value=value+1,过程分为三步:1、获得value的值。2、value的值加1。3、给value赋值。如果现在A线程在进行完第一步后,CPU将时间片分给B线程,那么B线程就会和A线程取得同样的值,这样的话,最后的结果很可能二者获得相同的值,很明显与我们想要的结果不符。为什么会造成这样的结果,因为在没有同步的情况下,编译器、硬件、运行时事实上对时间和活动顺序是很随意的。如何才能解决这个问题,这就是我们今天要讨论的问题:上锁!此处最简单的处理方法是在getValue方法上加synchronized关键字,变为:

public class Generator {

private int value = 1;

public synchronized int getValue(){

return value++;

}

}

复制代码
该类就是现程安全的了。具体为什么,我们后面的内容会放出,此处只为了引出线程安全问题。看完这个例子,我们再来重新理解下线程安全问题,一般情况下,如果一个对象的状态是可变的,同时它又是共享的(即至少可被多于一个线程同时访问),则它存在线程安全问题,总结来说:无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。(如果所有线程都是读取,不涉及写入,那么也就无需担心线程安全问题)有时我们存在侥幸心理:自己写的程序也没有按照上面的原理来实现同步,可是依然运行的好好的!不过,这种想法或者习惯是不好的,没有进行同步的多线程程序(前提是需要同步)永远都是不安全的,也许只是暂时没有出问题而已,甚至可能几年内都不可能出问题,但是,这是未知数,程序存在安全隐患,任何时刻都有可能breakdown!谁都不希望自己的应用是这样的吧?想排除隐患有以下三个方法:1、禁止跨线程访问变量。2、使状态变量为不可变。3、使用同步。(前两个方法实际就是放弃使用多线程,这不符合我们的个性,我们需要解决问题,而非逃避问题)。相信说了这么多,有不少读者已经很急切的想知道:如此神秘的线程同步到底有哪些方法,下面我将一一介绍。

线程同步的主要方法

原子性

大家应该还记得我们之前说过的value++那个小程序,此处的value++就是非原子操作,它是先取值、再加1、最后赋值的一种机制,是一种“读-写-改”的操作,原子操作需要保证,在对对象进行修改的过程中,对象的状态不能被改变!这个现象我们用一个名词:竞争条件来描述。换句话说,当计算结果的正确性依赖于运行时中相关的时序或者多线程的交替时,会产生竞争条件。(即想得到正确的答案,要依赖于一定的运气。正如value++中的情况,如果我的运气足够好,在对value进行操作时,无其它任何线程同时对其操作)正如《JAVA
CONCURRENCY IN PRACTICE》一书中所述的例子:你打算中午12点到学习附近的星巴克见一个朋友,当你到达后,发现这里有两个星巴克,而你不确定和朋友约了哪个,12:10的时候,你在星巴克A依然没有见到你的朋友,于是你向B走去,到了发现他也不在星巴克B,此时有几种可能:你的朋友迟到了,没有到任何一个;你的朋友在你离开后到达了A;你的朋友先到了B,在你去B找他的时候,他却来了A找你;不妨我们假设一种最糟糕的情况:你们就这么来来回回走了很多趟,依然没有发现对方,因为你们没有做好约定!这个例子就是说,当你期望改变系统状态时(你去B找你的朋友),系统状态可能已经改变(你的朋友也正从B走来,而你却不知)。这个例子阐释清楚了引发竞争条件的真正原因:为了获取期望的结果(去B找到朋友),需要依赖相关事件的分时(朋友在B等待,直到你的出现)。这种竞争条件被称作:检查再运行(check-then-act):你观察的事情为真(你的朋友不在星巴克A),你会基于你的观察执行一些动作(去B找你的朋友),不料,在你从观察到执行动作的时候,之前的观察结果已无效(你的朋友可能已经出现在A或者正往A走)。这样就回引发错误。此处读者朋友们可以阅读我的一篇关于设计模式文章的介绍,里面说到单例模式时,有这样的一段代码:

public static Singleton getInstance() {

if (instance == null) {

instance = new Singleton();

}

return instance;

}

复制代码
和之前的value++类似,有可能两个线程同时检测到instance为null,CPU通过切换时间片来执行两条线程,结果最后返回了两个不同的实例,这是我们不想看到的结果。我们还来看个value++这个例子,稍作修改:

public class Generator {

private long value = 1;

public void getValue(){

value++;

}

}

复制代码
我们如何通过原子变量,将其转为线程安全的呢?在java.util.concurrent.atomic包下有一些将数字和对象引用进行原始状态转换的类,我们改改这个程序:

public class Generator {

private final AtomicLong value = new AtomicLong(0);

public void getValue(){

value.incrementAndGet();

}

}

复制代码
这样这个类就是线程安全的了。此处我们通过原子变量来解决,之前我们使用synchronized关键字来解决的,两个方法都行。

加锁

内部锁(synchronized)

Java提供了完善的内置锁机制:synchronized块。在方法前synchronized关键字或者在方法中加synchronized语句块,锁住的都是方法中包含的对象,如果线程想获得所,那么就需要进入有synchronized关键字修饰的方法或块。如果大家读过我前面的一篇博文关于HashMap的(/article/1354311.html),里面有关于synchronized锁住对象的分析,采用synchronized有时会带来一定的性能下降。但是,无疑synchronized是最简单实用的同步机制,基本可以满足日常需求。内部锁扮演了互斥锁(即mutex)的角色,意味着同一时刻至多只能有一个线程可以拥有锁,当线程A想去请求一个被线程B占用的锁时,必然会发生阻塞,知道B释放该锁,如果B永不释放锁,A将一直等待下去。这种机制是一种基于调用的机制(每调用,即per-invocation),就是说不管哪个线程,如果调用声明为synchronized的方法,就可获得锁(前提是锁未被占用)。还有另一种机制,是基于每线程的(per-thread),就是我们下面要介绍的重进入——Reentrancy。

重进入(Reentrancy)

重进入是一种基于per-thread的机制,并不是一种独立的同步方法 。基本实现是这样的:每个锁关联一个请求计数器和一个占有它的线程,当计数器为0时,锁是未被占有的,线程请求时,JVM将记录锁的占有者,并将计数器增1,当同一线程再次请求这个锁时,计数器递增;线程退出时,计数器减1,直到计数器为0时,锁被释放。

可见性和过期数据

可见性,可以说是一种原始概念,并不是一种单独的同步方法,就是说,同步可以实现数据的可见性,和避免过期数据的出现。如之前我们讲的星巴克的例子,当我从星巴克A离开去B找朋友的时候,我并不知道朋友及星巴克A发生了什么,这就是不可见的,反过来讲,如果我能清楚的知道:在我去B之前,朋友绝对不会离开B,(也就是说,我对整个状态一清二楚)(事实上,这需要提前约定好),这就是可见的了,因此也不会发生其他问题,朋友会在B一直等我,直到我的出现!再举一个例子,如两个线程A和B,A写数据data,B读取数据data,某一个时刻二者同时得到data,在A提交写之前,B已经读取,这样就回造成B所读取的数据不是最新的,是过期的,这就是过期数据,过期数据会对程序造成不好的影响。关于可见性方面,同步机制看下面的Volatile变量。

显示锁

如果大家还记得ConcurrentHashMap,那么理解显示锁就比较容易了,顾名思义,显示锁表面意思就是现实的调用锁,且释放锁。它提供了与synchronized基本一致的机制。但是有synchronized不能达到的效果,如:定时锁的等待、可中断锁的等待、公平性、及实现非块结构的锁。但是为什么还用synchronized呢?其实,用显示锁会比较复杂,且容易出错,如下面的代码:

Lock lock = new ReentrantLock();

...

lock.lock();

try{

...

}finally{

lock.unlock();

}

复制代码
当我们忘记在finally里释放锁(这种概率很大,而且很难察觉),那么我们的程序将陷入困境。而是用内部锁synchronized简单方便,无需顾忌太多,所以,这就是为什么synchronized依然用的人很多,依然是很多时候线程同步的首选!
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: