java并发编程基础——线程安全

线程安全

线程安全性代表了并发程序的正确性,指的是在多线程环境下,应用程序始终能够表现出正确的行为。

问题根源

所有的线程安全问题,都可以归结为同一个原因:共享的可变状态。 “共享”意味着变量可以由多个线程同时访问,“可变”则意味着变量的值在其生命周期内可以发生变化。
编写线程安全代码核心就是对共享可变状态的访问操作进行管理,通过引入同步机制来保证任意时刻只有一个线程访问共享可变状态。

共享可变对象

在并发编程中,共享可变对象会面临以下3个问题:

原子性

竞态条件

当某个计算的正确性取决于多个线程的交替执行顺序时,那么就会发生竞态条件。常见两种静态模式:

  • read-modify-write(读-改-写)操作,该操作细化步骤:读取一个共享变量值(read),然后根据该值做些计算(modify),接着更新共享变量的值(write)。
  • check-then-act(监测后操作),细化步骤:读取某个共享变量的值,根据该变量值决定下一步动作是什么。

read-modify-write的示例:

@NotThreadSafe
public class UnsafeCounter {
  private int value;

  public int getNext() {
      return value++;
  }
}
复制代码

其中自增操作value++包含了三步子操作:读取value的值,将值加1,最后计算结果写入到vlue值,典型的读改写竞态模式。
check-then-act的示例:

@NotThreadSafe
public class UnsafeSequence {
  private int sequence;
  
  public int getSequence() {
      if(sequence >= 99){ // 步骤1 检查共享变量
          return 0;      // 步骤2 act 检查后操作
      } else {
          return sequence++;
      }
  }
}
复制代码

如何避免竞态条件问题,就需要引入原子操作来保证某个线程在修改共享变量过程中,其他线程不可操作,其他线程只能在原子操作前或原子操作后读取和修改共享变量状态。下面我们来看下什么是原子操作:

原子操作

对于涉及共享变量的访问操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。从概念看注意以下两点:

  • 原子操作是针对访问共享变量操作而言的,对于局部变量无所谓是否原子
  • 原子操作是针对多线程环境才有意义

同时不可分割有以下两层含义:

  • 访问(读、写)某个共享变量从其执行线程以外的线程来看,该操作要么已经执行结束,要么尚未发生,不存在执行中这个状态
  • 访问统一组共享变量操作是不能够被交错的。

java中两种方式实现原子性:

  1. 使用锁(Lock)锁具有排他性,能搞保障一个共享变量在任意时刻只能够被一个线程访问(关于锁将单独有一章分享)。
  2. 利用CAS指令,CAS指令是直接在硬件处理器和内存这一层次的实现,可以被看做是硬件锁(CAS也会单独一章分享)。

可见性

在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永久无法读取到这个更新结果。这个是线程安全的另一个表现形式:可见性。
可见性问题是由于java的内存模型决定的,具体可以参考java并发编程——内存模型
java平台如何保证可见性:

  • volatile关键字,用来确保将变量的更新操作通知到其他线程
  • 加锁机制既可以确保可见性又可以确保原子性
  • final可见性:被final修饰的字段在构造器中一旦完成,那么在其他线程就可以看见final字段值

有序性

现代微处理器会通过指令乱序执行(out-of-order execution)来提升执行效率,除了处理器,Java自身的JIT编辑器也会对指令做重排序,最终生成的机器指令可能与字节码顺序并不一致。 在并发程序中,指令重排序可能会导致预期之外的执行结果,比如以下的程序,在多线程执行时,线程1中的语句可能会被乱序执行,flg=true可能会先于a=1被执行,则线程2可能会出乎意料地打印出 a = 2。

java平台如何保证内存访问顺序性:

  • volatile关键字通过添加特定类型内存屏障指令来禁止处理器重排序
  • 加锁来禁止重排序

解决线程安全问题

避免共享状态

无状态的对象一定是线程安全的,典型代表Servlet程序,各个Servlet自身并不持有状态,彼此隔离,互相不干扰。如果持有状态不可避免,则可以使用线程封闭技术,将状态“隐藏起来”,不让别的线程访问到。常见的方法是栈封闭和ThreadLoca两种形式。 栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量访问对象,这些局部变量被封闭在执行线程的栈内部,其它线程无法访问到它们。

    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        // animals confined to method, don't let them escape!
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a))
                candidate = a;
            else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }
复制代码

在上面的代码中,animals和candidate是函数的局部变量,被封闭在栈帧内部,不会逸出,被其它线程访问到,所以该方法是线程安全的。关于ThreadLocal会有专门章节介绍,这里面不展开说明。

避免可变状态

如果某个对象在被创建后其状态就不能被修改,那么这个对象就被称为不可变对象。不可变对象一定是线程安全的。满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改
  • 对象所有属性都是final类型
  • 对象是正确创建的(创建期间,this引用没有逸出)

Guava库也提供了一组不可变类,比如ImmutabelList、ImmutableSet 这些,我们应该在代码中尽可能地使用它们

同步机制

如果共享和可变都无法避免,那么只有使用下策 —— 同步机制,来保证线程安全性。在Java代码中,通常使用synchronized关键字,对类或者对象加锁,来实现同步。具体java的同步机制有哪些将在下一章分析。

猜你喜欢

转载自juejin.im/post/5d6a804f51882571532c98ae