并发编程实践笔记(1)-线程安全

本文首发: 我的个人博客: JoeMendez’s Blog
欢迎大家一起交流

1. 线程安全

1. 并发的风险

  • 安全性问题

    • 在没有充足同步的情况下, 多个线程的操作执行顺序是不可预测的, 有可能产生奇怪的结果, 比如:

      public class UnsafeSequence{
          private int value;
          public int getValue(){
              return value++; // 竟态条件(Race Condition)
          }
      }
      // ++ 操作不是原子性操作, 在单线程时没有问题, 但是多线程时,就可能引发线程安全问题
      /*
          i++ 不是原子操作, 是3个原子操作组合
              1.读取主存中的count值, 赋值给一个局部成员变量tmp
              2.tmp+1
              3.将tmp赋值给count
          可能会出现线程1运行到第2步的时候, tmp值为1;这时CPU调度切换到线程2执行完毕, count值为1;切换到线程1, 继续执行第3步, count被赋值为1
          结果就是两个线程执行完毕, count的值只加了1, 而且两个线程获取到同样的值
      */
      public class Sequence{
          private int value;
          public synchronized int getValue(){
              return value++;  
          }
      }
      

      因为i++不是原子操作, 所以会引发多线程安全问题, 可以通过加synchronized关键字保证同步, 这样可以保证线程安全, 但是效率会变的很慢

    • 安全性的含义是"保证永远不会发生糟糕的事".

  • 活跃性问题

    • 当某个操作无法继续执行下去时, 就会发生活跃性问题. 在串行程序中活跃性问题的形式之一就是无线循环, 而在多线程中, 则是死锁, 饥饿, 活锁等.
    • 活跃性意味着某件事最终会发生

2. 线程安全性

  • 当多个线程访问某个状态变量并且其中有一个线程执行写入操作时, 必须采用同步机制来协同这些线程对变量的访问.

  • 完全由线程安全类构成的程序并不一定就是线程安全的, 而在线程安全类中也可以包含非线程安全的类, 只有当类中仅包含自己的状态时, 线程安全类才是有意义的

  • 当多个线程访问某个类时, 这个类始终都能表现出正确的行为, 那么就称这个类是线程安全的.(无状态(不包含任何域, 也不包含任何对其他类中域的引用)对象一定是线程安全的)

    public class StatelessFactorizer implements Servlet{
        public void service(ServletRequest req, ServletResponse resp){
            BigInteger i = extractFromRequest(req);
            BigIntergerp[] factors = factor[i];
            encodeIntoResponse(resp, factors);
        }
    }
    
非原子性问题解决(CAS)
  • 在 Java 并发领域, 我们解决并发安全问题最粗暴的方式就是使用 synchronized 关键字了, 但它是一种独占形式的锁, 属于悲观锁机制, 性能会大打折扣。volatile 貌似也是一个不错的选择, 但 volatile 只能保持变量的可见性, 并不保证变量的原子性操作。
  • CAS 全称是 compare and swap, 即比较并交换, 它是一种原子操作, 同时 CAS 是一种乐观机制。java.util.concurrent 包很多功能都是建立在 CAS 之上, 如 ReenterLock 内部的 AQS, 各种原子类, 其底层都用 CAS来实现原子操作
原子性问题分析
  • 查看 i++ 的机制

    public class AddTest {
        public volatile int i;
        public void add() {
            i++;
        }
    }
    

    通过javap -c AddTest反编译查看add方法的字节码

    public void add();
        Code:
        0: aload_0
        1: dup
        2: getfield      #2          // Field i:I
        5: iconst_1
        6: iadd
        7: putfield      #2          // Field i:I
        10: return
    
    • i++被拆分成了几个指令:
      1. 执行getfield拿到原始i
      2. 执行iadd进行加 1 操作;
      3. 执行putfield写把累加后的值写回i
    • 当线程 1 执行到加 1 步骤时, 由于还没有执行赋值改变变量的值, 这时候并不会刷新主内存区中的变量, 如果此时线程 2 正好要拷贝该变量的值到自己私有缓存中, 问题就出现了, 当线程 2 拷贝完以后, 线程1正好执行赋值运算, 立马更新主内存区的值, 那么此时线程 2 的副本就是旧的了, 出现了脏读.
  • 如何解决呢?

    • 在add方法上加上 synchronized 关键字

      public class AddTest {
          public volatile int i;
          public synchronized void add() {
              i++;
          }
      }
      

      解决了并发安全问题, 但是性能也大打折扣

CAS 底层原理

CAS 的思想很简单:三个参数, 一个当前内存值 V、旧的预期值 A、即将更新的值 B, 当且仅当预期值 A 和内存值 V 相同时, 将内存值修改为 B 并返回 true, 否则什么都不做, 并返回 false。

  • 查看 AtomicInteger 静态代码块片段:

    public class AtomicInteger extends Number implements java.io.Serializable {
        private static final long serialVersionUID = 6214790243416807050L;
    
        // setup to use Unsafe.compareAndSwapInt for updates
        private static final Unsafe unsafe = Unsafe.getUnsafe();
        private static final long valueOffset;
    
        static {
            try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
            } catch (Exception ex) { throw new Error(ex); }
        }
    
        private volatile int value;
        // 省略部分代码
    }
    

    这里用到了 sun.misc.Unsafe 类, 它可以提供硬件级别的原子操作, 它可以获取某个属性在内存中的位置, 也可以修改对象的字段值, 只不过该类对一般开发而言, 很少会用到, 其底层是用 C/C++ 实现的, 所以它的方式都是被 native 关键字修饰过的
    可以看得出 AtomicInteger 类存储的值是在 value 字段中, 并且获取了 Unsafe 实例, 在静态代码块中, 还获取了 value 字段在内存中的偏移量 valueOffset

    • 接下来的例子使用CAS技术保证了并发安全

      public class AddIntTest {
          public AtomicInteger i;
          public void add() {
              i.getAndIncrement();
          }
      }
      
      // getAndIncrement();
      public final int getAndIncrement() {
          return unsafe.getAndAddInt(this, valueOffset, 1);
      }
      
      // getAndAddInt()
      public final int getAndAddInt(Object var1, long var2, int var4) {
          int var5;
          do {
              var5 = this.getIntVolatile(var1, var2); // native方法
          } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));// native 方法, 自旋
          return var5;
      }
      
    • var5 通过 this.getIntVolatile(var1, var2)方法获取, 是个 native 方法, 其目的是获取 var1 在 var2 偏移量的值, 其中 var1 就是 AtomicInteger, var2 是 valueOffset 值

    • 那么 CAS 核心重点来了,compareAndSwapInt 就是实现 CAS 的核心方法,其原理是如果 var1 中的 value 值和 var5 相等,就证明没有其他线程改变过这个变量,那么就把 value 值更新为 var5 + var4,其中 var4 是更新的增量值;反之如果没有更新,那么 CAS 就一直采用自旋的方式继续进行操作(其实就是个 while 循环),这一步也是一个原子操作

    • 举例分析:

      1. 设定 AtomicInteger 的 value 原始值为 A,从 Java 内存模型得知,线程 1 和线程 2 各自持有一份 value 的副本,值都是 A。
      2. 线程 1 通过getIntVolatile(var1, var2)拿到 value 值 A,这时线程 1 被挂起
      3. 线程 2 也通过getIntVolatile(var1, var2)方法获取到 value 值 A,并执行compareAndSwapInt方法比较内存值也为 A,成功修改内存值为 B
      4. 这时线程 1 恢复执行compareAndSwapInt方法比较,发现自己手里的值 A 和内存的值 B 不一致,说明该值已经被其它线程提前修改过了
      5. 线程 1 重新执行getIntVolatile(var1, var2)再次获取 value 值,因为变量 value 被 volatile 修饰,所以其它线程对它的修改,线程 A 总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功
    • 查看 hospot 的源码hotspot\src\share\vm\prims\unsafe.cpp, 发现具体实现为:

      UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
      UnsafeWrapper("Unsafe_CompareAndSwapInt");
      oop p = JNIHandles::resolve(obj);
       // 根据偏移量,计算 value 的地址。这里的 offset 就是 AtomaicInteger 中的 valueOffset
      jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
      // 调用 Atomic 中的函数 cmpxchg,该函数声明于 Atomic.hpp 中
      return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
      UNSAFE_END
      
      // atomic.cpp
      unsigned Atomic::cmpxchg(unsigned int exchange_value,
                              volatile unsigned int* dest, unsigned int compare_value) {
      assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
      return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest,
                                         (jint)compare_value);
      
      • …没看懂
        1. 先想办法拿到变量value在内存中的地址。
        2. 通过Atomic::cmpxchg实现比较替换,其中参数x是即将更新的值,参数e是原内存的值
CAS的问题
  1. ABA问题
    • 有2个线程同时对同一个值(初始值为A)进行CAS操作,还有一个线程想要更新该值为A, 这3个线程如下:
      • 线程1,期望值为A,欲更新的值为B
      • 线程2,期望值为A,欲更新的值为B
      • 线程3,期望值为B,欲更新的值为A
    • 线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B, 然后线程3抢到了CPU时间片, 线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程

      ABA问题带来的危害:
      小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
      线程1(提款机):获取当前值100,期望更新为50,
      线程2(提款机):获取当前值100,期望更新为50,
      线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
      线程3(默认):获取当前值50,期望更新为100,
      这时候线程3成功执行,余额变为100,
      线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
      此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。

    • 通常,我们的乐观锁实现中都会带一个 version 字段来记录更改的版本, 每次变量更新的时候, 版本号+1, 避免并发操作带来的问题。在 Java 中,AtomicStampedReference 实现了这个处理方式。
  2. 循环时间长开销大
    • 如果CAS操作失败,就需要循环进行CAS操作(循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成CPU极大的开销

      这种循环也称为自旋

    • 解决方法: 限制自旋次数,防止进入死循环

  3. 只能保证一个共享变量的原子操作
    • CAS的原子操作只能针对一个共享变量
    • 解决方法: 如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,或者可以把多个共享变量合并成一个共享变量进行CAS操作
CAS的应用

CAS操作并不会锁住共享变量,也就是一种非阻塞的同步机制,CAS就是乐观锁的实现

  1. 乐观锁

    乐观锁总是假设最好的情况,每次去操作数据都认为不会被别的线程修改数据,所以在每次操作数据的时候都不会给数据加锁,即在线程对数据进行操作的时候,别的线程不会阻塞仍然可以对数据进行操作,只有在需要更新数据的时候才会去判断数据是否被别的线程修改过,如果数据被修改过则会拒绝操作并且返回错误信息给用户。

  2. 悲观锁

    悲观锁总是假设最坏的情况,每次去操作数据时候都认为会被的线程修改数据,所以在每次操作数据的时候都会给数据加锁,让别的线程无法操作这个数据,别的线程会一直阻塞直到获取到这个数据的锁。这样的话就会影响效率,比如当有个线程发生一个很耗时的操作的时候,别的线程只是想获取这个数据的值而已都要等待很久

Java利用CAS的乐观锁、原子性的特性高效解决了多线程的安全性问题,例如JDK1.8中的集合类ConcurrentHashMap、关键字volatileReentrantLock

参考文章:

CAS原理分析及ABA问题

Java并发之CAS原理分析

竟态条件
  • 由于不恰当的执行时序而出现不正确的结果就是竟态条件(Race Condition). 最常见的竟态条件类型就是"先检查后执行(check-then-Act)"操作, 即通过一个可能失效的观测结果来决定下一步的动作

    数据竞争(data race): 如果在访问共享的非final类型的域时没有采用同步来进行协同, 那么就会出现数据竞争.

    当一个线程写入一个变量, 而另一个线程接下来读取这个变量, 或者获取一个之前由另一个线程写入的变量时, 并且在这两个线程之间没有使用同步, 那么就可能出现数据竞争.

    如果存在数据竞争, 那么这段代码没有确定的语义, 并非所有的竟态条件都是数据竞争, 同样并非所有的数据竞争都是竟态条件, 但二者都可能使并发程序失败

    • 竟态条件并不总会产生错误, 还需要某种不恰当的执行时序
  1. 惰性初始化中的竟态条件(lazy initialization)

    • 目的是延迟对象的初始化, 直到程序真正使用它, 同时确保他只初始化一次(单例)
    @NotThreadSafe
    public class UnsafeLazyInitialization {
        private static Resource resource;
    
        public static Resource getInstance() {
            if (resource == null)
                resource = new Resource(); // unsafe publication
            return resource;
        }
    
        static class Resource {
        }
    }
    
  2. 复合操作

    • 复合操作: 先检查后执行, "读取-修改-写入"等操作的全部执行过程看作复合操作, 为了保证线程安全, 操作必须原子地执行.
    @ThreadSafe
    public class CountingFactorizer extends GenericServlet implements Servlet {
        // 原子类变量, 利用CAS原理实现
        private final AtomicLong count = new AtomicLong(0);
        public long getCount() { return count.get(); }
        @Override
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            // 原子操作
            count.incrementAndGet();
            encodeIntoResponse(resp, factors);
        }
        void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {}
        BigInteger extractFromRequest(ServletRequest req) {return null; }
        BigInteger[] factor(BigInteger i) { return null; }
    }
    
  • 尽管在程序中所有的变量都是原子操作, 但还是可能出现竟态条件, 可能产生错误的结果. 当不变性条件中涉及多个变量时, 各个变量之间并不是彼此独立的, 而是某个变量的值会对其他变量的值产生约束, 因此, 当需要更新某个变量时, 需要在同一个原子操作中对其他变量同时进行更新, 这就需要加锁来实现

    @NotThreadSafe
    public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
        private final AtomicReference<BigInteger> lastNumber
                = new AtomicReference<BigInteger>();
        private final AtomicReference<BigInteger[]> lastFactors
                = new AtomicReference<BigInteger[]>();
    
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            if (i.equals(lastNumber.get())) // 竟态条件
                encodeIntoResponse(resp, lastFactors.get());
            else {
                BigInteger[] factors = factor(i);
                lastNumber.set(i);//竟态条件, 每个set是安全的, 但是合起来就不一定了, 可能只更新了一个值然后被中断
                lastFactors.set(factors);
                encodeIntoResponse(resp, factors);
            }
        }
    }
    
  1. 内置锁
    Java中提供了一种内置的锁机制来支持原子性:
  • 同步代码块(Synchronized Block)
    同步代码块包括2部分, 一个作为锁的对象引用, 一个作为由这个锁保护的代码块.
    同步代码块的锁就是方法调用所在的对象, 静态的synchronized方法以Class对象作为锁 synchronized(lock) { // 由锁保护的共享状态 }
    每个Java对象都可以用做一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)监视器锁(Monitor Lock), 线程在进入同步代码块之前会自动获得锁, 并且在退出同步代码块后自动释放锁, 无论是正常退出还是抛出异常.
    Java的内置锁相当于一种互斥体, 即最多只有一个线程持有这种锁
  1. 重入
    当某个线程请求一个由其他线程持有的锁时, 发出的线程就会阻塞. 然而由于内置锁是可重入的(某个线程试图获取一个已经由他自己持有的锁, 那么这个请求会成功).
    重入意味着获取锁的操作的粒度是"线程",不是"调用".

    class Widget {
        public synchronized void doSomething() {
            System.ou.println("doSomething...");
        }
    }
    
    class LoggingWidget extends Widget {
        public synchronized void doSomething() {
            System.out.println(toString() + ": calling doSomething");
            super.doSomething();
        }
    }
    
    • 子类重写了父类的synchronized方法, 然后调用父类的方法, 如果没有可重入的锁, 那么这段代码将会产生死锁, 由于WidgetLoggingWidget中的doSomething方法都是synchronized方法, 因此每个doSomething方法执行前都会获取Widget上的锁, 如果内置锁是不可重入的, 那么调用super.doSomething时将无法获得Widget上的锁, 因为这个锁已经被持有.

参考资料:

CAS原理分析及ABA问题

Java并发之CAS原理分析

Java Concurrency in Practice(Java并发编程实践) [作者: Brian Goetz]

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值