[关闭]
@frank-shaw 2015-10-29T11:04:07.000000Z 字数 4838 阅读 2567

线程安全

java.多线程


定义

线程安全的定义:
当多个线程访问一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

这个定义比较严谨,它要求线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。这听起来很简单,但是真正实现起来很难。

Java语言中的线程安全

我们这里讨论的线程安全,就限定于多个线程之间存在共享数据访问这个前提。

为了更加深入地理解线程安全,在这里我们可以不把线程安全当做一个非真即假的二元排他选项来看待,按照线程安全的"安全程度"由强至弱来排序,我们可以将Java语言下各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程独立。

不可变

在Java中,不可变的对象一定是线程安全的。无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保护措施。这是通过final关键字来实现的。因为改变不了啊,还有什么安不安全的说法呢?那个东西你根本都偷不走,还需要护卫这种东西么?

一个典型的类就是java.lang.String类。

绝对线程安全

绝对的线程安全完全满足之前对线程安全的定义,这个类要达到"不管运行时环境如何,调用者都不需要任何额外的同步措施"通常需要付出很大的代价。

在Java API中标注自己是线程安全的类,大多都不是绝对的线程安全。我们通过java.util.Vector这个线程安全的容器来看看这里的绝对是什么意思。Vector容器类都很熟悉,它的add() get() size()这类方法都被synchronized修饰的,尽管这样效率很低,但确实是安全的。但是,即使它所有的方法都被修饰为同步,也不意味着调用它的时候永远都不再需要同步手段了。我们来看下面这段代码:

它的运行结果是:

很明显,尽管这里使用到的Vector的get()、remove()和size()方法都是同步的,但是在多线程的环境下,不过不在方法调用端做额外的同步措施的话,使用这段代码仍然是不安全的,因为如果另一个线程恰好在错误的时间删除一个元素,导致序号i已经不再可用的话,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException。

get(i)的规格说明里有一条前置条件要求i必须是非负的并且小于size()。但是,在多线程环境中,没有办法可以知道上一次查到的size()值是否仍然有效,因而不能确定i

更明确地说,这一问题是由 get() 的前置条件是以 size()的结果来定义的这一事实所带来的。只要看到这种必须使用一种方法的结果作为另一种讲法的输入条件的样式,它就是一个状态依赖,就必须保证至少在调用这两种方法期间元素的状态没有改变。

如果要保证这段代码能正确地执行下去,我们不得不把两个线程定义为如下:

相对线程安全

相对的线程安全就是我们通常意义下的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在Java中,大部分的线程安全类就是属于这种类型,例如Vector、Hashtable、Collections的synchronizedCollection()方法包装的集合等。都是。--这样子我也就能够明白为什么论坛上有人在质疑Hashtable不是线程安全的了。我们对线程安全的定义不一样。

线程兼容

线程兼容指的是对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数情况是指这种情况。很多类都是线程兼容的,如ArrayList、HashMap.

线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。一个线程对立的例子是Thread类的suspend()和resume()方法。

死锁的情况:锁的同步嵌套

两个人去下馆子,但是每个人都拿到一根筷子,筷子就是我们这里的锁,那我们需要有两根筷子才能够吃饭的呀,对吧。有两种较为极端的情况:

1.哥两好呀,你把筷子给我,我吃一口,还你筷子,同时把我的筷子给你,你吃一口,这就是一种和谐社会的情景。
2.哥两不好啊。分别都想要先吃,于是乎,我说:你把筷子给我,我先吃! 另一个人说:不,你把筷子给我,我先吃。于是乎,一顿争执,大吵大闹,到最后谁都吃不了饭,这就是死锁情况。

死锁的例子:

  1. //线程类实现了Runnable
  2. class Test implment Runnalble {
  3. private boolean flag;
  4. public Test(boolean flag){
  5. this.flag = flag;
  6. }
  7. public void run(){
  8. if(flag){//线程true执行部分
  9. while(ture){
  10. synchronized(Lock.lockA){
  11. System.out.println("true ...lockA");
  12. synchronized(Lock.lockB){
  13. System.out.println("true ...lockB");
  14. }
  15. }
  16. }
  17. }
  18. else{//线程false执行部分
  19. while(ture){
  20. synchronized(Lock.lockB){
  21. System.out.println("false ...lockB");
  22. synchronized(Lock.lockA){
  23. System.out.println("false ...lockA");
  24. }
  25. }
  26. }
  27. }
  28. }
  29. }
  30. //锁
  31. class Lock {
  32. public static Object lockA = new Object;
  33. public static Object lockB = new Object;
  34. }
  35. public class LockTest{
  36. public static void main(String[] args){
  37. Test a = new Test(true);
  38. Test b = new Test(false);
  39. Thread t1 = new Thread(a);
  40. Thread t2 = new Thread(b);
  41. t1.start;
  42. t2.start;
  43. }
  44. }

线程安全的实现方法

我们知道了什么是线程安全之后,紧接着的一个问题就是我们应该如何实现线程安全,当然,我们可以通过编写代码来实现,但如果我们能够很好地借助虚拟机提供的同步和锁机制来帮助我们的话,我想我们的代码编写起来会更加容易些。下面几种方法都需要理解,必要时候可以采用。

互斥同步

互斥同步是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。而互斥是实现同步的一个手段,临界区、互斥量、信号量都是主要的互斥实现方式。

互斥是因,同步是果;互斥是方法,同步是目的。

最基本的互斥同步手段就是synchronized关键字,这个关键字需要明确指定锁的对象(任意一个类都可以,所以可以选择Object类)。
synchronized是Java语言中一个重量级的操作,对于代码简单的同步块,状态转换(从用户态转到核心态)消耗的时间可能比用户代码执行的时间还要长。有经验的程序员都会在确实必要的情况下才使用这个操作。

另外一个方式就是使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别:ReentrantLock表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语法块来完成),而synchronized表现为原生语法层面的互斥锁。

相比于synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁以及锁可以绑定的多个条件。

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,这种同步叫阻塞同步。从处理问题的方式上来说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出问题,无论共享数据是否真的出现了竞争,他都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

非阻塞同步

我们现在可以有另一种选择:基于冲突检测的乐观并发策略,通俗地讲,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断重试,直到成功为止),这种乐观的并发策略的实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

乐观并发策略需要"硬件指令集的发展"才能进行。因为我们需要操作盒冲突检测这两个步骤具备原子性,靠什么来保证呢?如果这里在使用互斥同步来保证就失去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一个处理器指令就能够完成,这类指令常用的有:

  1. 测试并加置
  2. 获取并增加
  3. 交换
  4. 比较并交换(CAS)
  5. 加载链接/条件存储

    在JDK1.5之后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者认为是无条件内联进去了。

    由于Unsafe类不是提供给用户程序调用的类,因此入股不采用反射手段,我们只能通过其他的Java API来间接使用它,如java.util.concurrent包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。

无同步方案

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时候的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。因此会有一些代码天生就是线程安全的,笔者简单介绍两类:

可重入代码
这种代码也叫作纯代码,可以在代码执行的任何时刻去中断它,转而去执行另一段代码,而在控制权返回的时候,原来的程序不会发生任何错误。

重入即表示重复进入,首先它意味着这个函数可以被中断,其次意味着它除了使用自己栈上的变量以外不依赖于任何环境(包括 static),这样的函数就是purecode(纯代码)可重入,可以允许有该函数的多个副本在运行,由于它们使用的是分离的栈,所以不会互相干扰。

线程本地存储

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注