鱼C论坛

 找回密码
 立即注册
查看: 2965|回复: 2

[技术交流] Java之线程同步

[复制链接]
发表于 2016-12-22 16:11:53 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能^_^

您需要 登录 才可以下载或查看,没有账号?立即注册

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

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

  我们来看个小例子:

  [java] view plain copy

  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关键字,变为:

  [java] view plain copy

  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的(http://blog.csdn.Net/zhangerqing/article/details/8193118),里面有关于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依然用的人很多,依然是很多时候线程同步的首选!

  读写锁

  有的时候,数据是需要被频繁读取的,但不排除偶尔的写入,我们只要保证:在读取线程读取数据的时候,能够读到最新的数据就不会问题。此时符合读-写锁的特点:一个资源能够被多个线程读取,或者一个线程写入,二者不同时进行。这种特点,在特定的情况下有很好的性能!

  Volatile变量

  这是一种轻量级的同步机制,和前面说的可见性有很大关系,可以说,volatile变量,可以保证变量数据的可见性。在Java中设置变量值的操作,对于变量值的简单读写操作没有必要进行同步,都是原子操作。只有long和double类型的变量是非原子操作的。JVM将二者(long和double都是64位的)的读写划分为两个32位的操作,这样就有可能就会不安全,只有声明为volatile,才会使得64位的long和double成为现场安全的。当一个变量声明为volatile类型后,编译器会对其进行监控,保证其不会与其它内存操作一起被重排序(重排序:举个例子,num=num+1;flag=true;JVM在执行这两条语句时,不一定先执行num=num+1,也许在num+1赋值给num之前,flag就已经为true了,这就是一种重排序),同时,volatile变量不会被进行缓存,所以,每当读取volatile变量时,总能得到最新的值!为什么会这样?我们来看下面这段话:在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,只有把该变量声明为volatile,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。Java语言规范中指出:为了获得最佳速度,允许线程保存共享成员变量的私有拷贝,而且只当线程进入或者离开同步代码块时才与共享成员变量的原始值对比。这样当多个线程同时与某个对象交互时,就必须要注意到要让线程及时的得到共享成员变量的变化。而volatile关键字就是提示JVM:对于这个成员变量不能保存它的私有拷贝,而应直接与共享成员变量交互。

  此处注意:volatile关键字只能保证线程的可见性,但不能保证原子性,试图用volatile保证原子性会很复杂!

  一般情况,volatile关键字用于修饰一些变量,如:被当做完成标识、中断、状态等。满足一下三个条件的情况,比较符合volatile的使用情景:

  1、写入变量时并不依赖变量的当前值(否则就和value++类似了),或者能够确保只有单一线程修改变量的值。

  2、变量不需要与其他的状态变量共同参与不变约束。

  3、访问变量时,没有其它原因需要加锁。(毕竟加锁是个耗性能的操作)

  使用建议:在两个或者更多的线程访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,不必使用。由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。

  Semaphore(信号量)

  信号量的意思就是设置一个最大值,来控制有限个对象同时对资源进行访问。因为有的时候有些资源并不是只能由一个线程同时访问的,举个例子,我这儿有5个碗,只能满足5个人同时用餐,那么我可以设置一个最大值5,线程访问时,用acquire() 获取一个许可,如果没有就等待,用完时用release() 释放一个许可。这样就保证了最多5个人同时用餐,不会造成安全问题,这是一种很简单的同步机制。

  临界区

  如果有多个线程试图同时访问临界区,那么在有一个线程进入后,其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的。在使用临界区时,一般不允许其运行时间过长,只要进入临界区的线程还没有离开,其他所有试图进入此临界区的线程都会被挂起而进入到等待状态,并会在一定程度上影响程序的运行性能。尤其需要注意的是不要将等待用户输入或是其他一些外界干预的操作包含到临界区。如果进入了临界区却一直没有释放,同样也会引起其他线程的长时间等待。

  同步容器

  Java为我们提供非常完整的线程同步机制,这包括jdk1.5后新增的java.util.concurrent包,里面包含各种各样出色的线程安全的容器(即集合类)。如ConcurrentHashMap,CopyOnWriteArrayList、LinkedBlockingDeque等,这些容器有的在性能非常出色,也是值得我们程序员庆幸的事儿!

  Collections位集合类提供线程安全的支持

  对于有些非线程安全的集合类,如HashMap,我们可以通过Collections的一些方法,使得HashMap变为线程安全的类,如:Collections.synchronizedMap(new HashMap());

  excutor框架

  Java中excutor只是一个接口,但它为一个强大的同步框架做好了基础,其实现可以用于异步任务执行,支持很多不同类型的任务执行策略。excutor框架适用于生产者-消费者模式,是一个非常成熟的框架,此处不多讲,在后续的文章中,我会细细分析它!

  事件驱动

  事件驱动的意思就是一件事情办完后,唤醒其它线程去干另一件。这样就保证:1、数据可见性。在A线程执行的时候,B线程处于睡眠状态,不可能对共享变量进行修改。2、互斥性。相当于上锁,不会有其它线程干扰。常用的方法有:sleep()、wait()、notify()等等。

评分

参与人数 1鱼币 +5 收起 理由
零度非安全 + 5 感谢楼主无私奉献!

查看全部评分

想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复

使用道具 举报

发表于 2016-12-22 20:05:51 | 显示全部楼层
我还是先把基础打好
想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复 支持 反对

使用道具 举报

 楼主| 发表于 2016-12-23 13:15:53 | 显示全部楼层
零度非安全 发表于 2016-12-22 20:05
我还是先把基础打好

加油!
想知道小甲鱼最近在做啥?请访问 -> ilovefishc.com
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Archiver|鱼C工作室 ( 粤ICP备18085999号-1 | 粤公网安备 44051102000585号)

GMT+8, 2024-3-29 13:43

Powered by Discuz! X3.4

© 2001-2023 Discuz! Team.

快速回复 返回顶部 返回列表