9. 线程安全

偶尔善良 提交于 2020-01-11 06:23:26

       当多个线程访问一个对象时,若不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。-----摘自《Java Concurrency  in Practice》

一、Java中的线程安全

 Java中各种操作共享数据可以分为以下几种:

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立

1.  不可变

       在Java中不可变得对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。若共享数据是一个基本数据类型,只要在定义事使用 final 关键字来修饰就它可以保证它是不可变的。若共享数据是一个引用对象,则还需要保证对象的行为不会对其状态产生任何影响才行。 保证对象行为不影响自己状态最简单的就是将对象中带有状态的变量都声明为 final。

2. 绝对线程安全

        绝对安全完全满足最上面给出的定义。在Java API中标注自己是现成安全的类,大多数都不是绝对线程安全的。必须要进行相应的同步处理。

3. 相对线程安全

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

4. 线程兼容

       它是指对象本身并不是线程安全的,但是可以通过在调用端正确使用同步手段来保证对象在并发环境中可以安全地使用,我们平常一个类不是线程安全的,绝大多数时候指的就是这种情况。

5.  线程对立

        它是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。

二、线程安全的实现方法

1. 互斥同步

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

        Java中,最基本的互斥同步手段就是使用 synchronized 关键字,synchronized关键字经过编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,且这两个对象需要使用一个 reference 类型的参数来指明要锁定和解锁的对象。 

       根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。若这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行 monitorexit 指令时会将锁计数器减1,当计数器为0时,锁就被释放。若获取对象的锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

       还可以使用JUC包中的重入锁(ReentrantLock)来实现同步。基本用法上,ReentrantLock与synchronized相似,但是在代码写法上有点区别,一个表现为 API 层面的互斥锁(lock() 和 unLock() 方法配合 try/finally语句来完成),另一个表现为原生语法层面的互斥锁。但相比于 synchronized, ReentrantLock 增加了一些高级功能,主要有三个:等待可中断、可实现公平锁、锁可以绑定多个条件。

       在JDK1.6之前,多线程环境下 synchronized 的吞吐量下降非常严重,而 ReentrantLock 基本保持在同一个比较稳定的水平上。但在JDK1.6中对 synchronized 进行了大量的优化,性能基本上与 ReentrantLock 持平。

2. 非阻塞同步

       互斥同步最主要的问题是解决进行线程阻塞和唤醒所带来的性能问题,它也被称为阻塞同步。互斥同步属于一种悲观的并发策略,总认为只要不去同步就会出现问题,无论共享数据是否会真的出现竞争,它都要进行加锁、用户态核心转换等操作。随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,简单说,就是先进行操作,若没有其他线程争用共享数据那操作就成功了;若共享数据有争用,产生了冲突,就采用其他线程争用的补偿措施(比如不断重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起。这种同步操作称为非阻塞同步

-----------------------------------------------------------------------------------------------------------------------------------------------------------------------

        CAS是乐观的实现,它需要三个操作数:内存的位置(V)、旧的预期值(A)和新值(B)。CAS指令执行时,当且仅当V符合就得预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V值,都会返回V的旧值。且这个操作是一个原子操作。在JDK1.5 之后才能使用。

        CAS存在一个称为ABA的问题:若一个变量V的值刚开始是A,期间它的值被改成了B,后来又被改回可A。CAS则会误认为它从来没有被改变过。JUC包为了解决这个问题,提供了一个带有标记的原子引用类"AtomicStampedReference",它可以通过控制变量值的版本来保证CAS的原子性。

3.  无同步方案

         有一些代码天生就是线程安全的比如以下这两类:

  1. 可重入代码:也被称为纯代码,可以在代码执行的任何时候中断它,转而去执行另一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有可重入的代码都是线程安全的,但是并非所有线程安全的代码都是可重入的。判断代码是否可重入的原则:若一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入的要求,也是线程安全的。
  2. 线程本地存储:若一段代码中所需的共享数据能够保证在同一个线程中执行,无须同步也能保证线程之间不出现数据争用的问题。比如常见的消费队列架构模式。

       Java中,若一个变量要被多线程访问,可以使用 volatile 关键字声明它是“易变”的;若一个变量要被某个线程独享,可以通过java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个ThreadLocal对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值可以在线程的K-V值中找回对应的本地线程变量。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!