Java并发之线程安全

Java并发之线程安全

编写线程安全的代码,核心在于对状态访问操作进行管理,特别是共享的和可变的状态的访问。

  • 共享意味着变量可以由多个线程进行访问;
  • 可变意味着变量的值在其生命周期内可变化

当有多个线程访问同一个可变的状态变量时,没有使用合适的同步,那么程序就会出现错误,有3种方法可以修复这个问题:

  • 不在线程之间共享状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

什么是线程安全性?

在多个线程访问一个类时,如果不用考虑这些线程在运行时的调度,并且不需要额外的同步及调用的代码不需要作其他的协调,这个类的方法仍是正确的,那么称这个类线程安全。线程安全的类封装了必要的同步,对线程安全类的实例进行顺序或并发的一系列操作,都不会导致实例处于无效状态。

“线程安全等级”

为了便于理解,Java语言中的线程安全程度可以分为以下5类:

  • 不可变
    在Java中,不可变对象一定是线程安全的。一旦线程试图修改对象,只会得到一个新的对象。不会影响原来的值。
  • 绝对线程安全
    一个类不管运行时环境如何,调用者都不需要任何额外的同步措施。这样可以被称为绝对线程安全。要完全达到这样的要求,需要很大的代价。很多标注为线程安全的Java API 也不能算作线程安全,例如 Vector是线程安全的容器,很多方法被synchronized修饰。但是在调用它的时候,仍需要考虑同步手段(remove(),size()方法和get()方法一起使用时,可能会导致ArrayIndexOutOfBoundException)。
  • 相对线程安全
    相对线程安全就是指我们通常意义上所讲的线程安全,它需要保证对象的单独操作是线程安全的,不需要额外的同步手段。但是对于特定顺序的连续调用,就可能需要在调用端使用额外的同步手段。
  • 线程兼容
    线程兼容是指对象本身不是线程安全的,但是可以通过调用端正确地使用同步手段来保证对象的并发安全。
  • 线程对立
    线程对立是指无论调用端是否采用同步措施,都无法在多线程环境中并发使用的代码。例如,同时使用Thead类的suspend()resume()方法,存在导致死锁的风险。

原子性

以递增操作Count ++为例,表面上这只是一个操作,实际上包含了3个独立的操作”读取-值修改-写入”,多线程执行时,不属于原子操作。这种由于不恰当的执行时序而出现不正确的结果,叫做竞态条件。当某个计算的正确性取决于多个线程的执行交替时序时,就会发生竞态条件。

最常见的竞态条件出现情况是”先检查后执行(check then act)“,即通过一个可能失效的结果来决定下一步的动作。”先检查后执行(check then act)“ 的一个常见的例子是单例模式懒加载,单例为null的判断。

对于”读取-值修改-写入”,”先检查后执行(check then act)“,这类的复合操作,可以通过加锁来保证其原子性。

互斥同步

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

java 中的 synchronizedLock 都是互斥同步。Lock的逻辑是先获得锁再执行,所以lock实质上是悲观锁,虽然lock使用了cas技术,但是要注意到,waiter 队列是按顺序试图获得锁的(非公平锁只是对于当前新加入的waiter,可以不排队),lock的cas更多的用于处理排队。

非阻塞同步

互斥同步最主要的问题是进行线程阻塞和唤醒锁带来的性能问题(内核态,上下文切换),这种同步也被称为阻塞同步。互斥同步属于一种悲观的并发策略,认为只要不去做正确的同步措施,那就一定会出现问题。

随着硬件指令集的发展,我们有了另外一个选择: 基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他进程竞争,那么就操作成功。如果有竞争,产生了冲突,那就采取其他的补偿措施(常见的如,不断重试直至成功),这种乐观策略的许多实现不需要挂起线程,因此被称为非阻塞同步。

CAS 指令有3个操作数,分别是内存位置V,旧的预期值A和新值B,CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则就不执行更新,但是无论是否更新成功,都会返回V的旧值,上述处理过程是一个原子操作。

可见性

在多线程的读写操作里,无法保证读操作的线程能实时的看到其他线程的写入,为了保证多线程对内存操作的可见性,需要使用同步机制。在没有同步的情况下,程序可能会得到失效数据,此外,对于64位数值double和long的非原子操作(2个32的复合操作),读取时可能会得到新值得高32位和失效数据的低32位。

  • 乐观锁和悲观锁:加锁的意义在于同时只有一个线程访问程序,因此,锁可以保证内存的可见性。
  • volatile: Java 提供了一种弱同步机制,volatile变量,保证了变量的可见性。

有序性

  • 乐观锁和悲观锁: 加锁的意义在于同时只有一个线程访问程序,因此,锁可以保证有序性。
  • volatile: volatile 实现了有序性的语义。

线程安全-对象构造过程

发布一个对象是指,使对象能够在当前作用域之外的地方使用。当一个不应该发布的对象被发布时,这个情况叫做逃逸。

当发布一个对象时,该对象的非私有域中引用的所有对象同样会被发布,此外发布内部类实例时,也可能隐含地发布对应的外部类。

当在在对象构造时,发生this引用逃逸,可能会发布了一个还没构造完成的对象。这种对象被认为是不正常构造。例如在构造函数中创建并启动一个线程,this引用都会被新创建的线程共享。因此,建议通过其他方法例如start或initialize来启动线程。

很容易忽略的是构造函数的指令重排序问题

要安全的发布一个对象,对象引用及对象状态必须同时对其他线程可见。一个正确构造的对象可以通过以下的方式发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或原子类型的对象中。
  • 将对象的引用保存到某个正确构造对象的final域。
  • 将对象引用保存到一个由锁保护的域中,例如线程安全的容器。

发布一个静态构造的对象,最简单的方法是使用静态的初始化器:public static Holder holder = new Helder(),静态初始化器由JVM在类的初始化阶段执行,由于JVM内部同步机制,因此可以安全发布。

对象的发布取决于它的可变性:

  • 不可变的对象可以通过任意机制来发布
  • 事实不可变对象必须通过安全方式发布
  • 可变对象必须通过安全方式发布,且必须是线程安全的或者是由某个锁保护。

事实不可变对象: 对象是可变的,但是发布后将不可变,例如,放入SynchronizedMap的可变类。

线程安全-线程封闭

访问共享的可变数据时,一种避免使用同步的方式就是不共享数据,这叫作线程封闭。

  • ThreadLocal: threadLocal在线程中创建变量的独立副本。
  • 栈封闭: 在栈封闭中只有通过局部变量才能访问变量,局部变量位于栈中,其他线程无法访问执行线程的栈。

线程安全-不变性(只读共享)

满足同步需求的另一种方法是使用不可变对象。不可变对象一定是线程安全的。

  • 对象创建后,状态就不可变;
  • 对象所有的域是final的
  • 对象是正确创建的,构造没有发生this 逃逸

对于在访问或者更新多个相关变量时出现竞争条件的问题,可以通过将这些变量全部保存在一个不可变对像中来消除。

-------------本文结束感谢您的阅读-------------
坚持分享,您的支持将鼓励我继续创作!
0%