Java虚拟机中线程安全

为什么需要多线程

为了合理利用 CPU 的高性能,平衡 CPU、内存、I/O 设备的速度的差异

  • CPU 增加了缓存,均衡与内存的速度差异; //导致可见性问题
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O设备的速度差异; // 导致原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。// 导致 有序性 问题

若多个线程对同一个共享数据进行访问而不采取同步操作,操作结果是不一致的

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add(){
        cnt++;
    }

    public int get(){
        return cnt;
    }

    public static void main(String[] args) throws InterruptedException {
        final int threadSize = 1000;
        ThreadUnsafeExample example = new ThreadUnsafeExample();
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < threadSize; i++) {
            executorService.execute(() -> {
                example.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println(example.get());
    }
}

// 结果总是小于 1000

并发出现问题的根源:并发三要素

可见性:cpu 缓存引起
  • 一个线程对共享变量的修改,另外一个程序能够立刻看到

    // 线程1执行的代码
    int i = 0;
    i = 10;
    
    // 线程2执行的代码
    j = i;
    

    执行线程1的是 CPU1,执行线程2的是CPU2。当线程1执行 i = 10时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为10,此时在 CPU1 的缓存中 i 的值变为10了,但没有立即写入到内存中。而此时,线程2执行 j = i,它会先去主存读取 i 的值并加载到 CPU2 的缓存中,此时内存中 i 的值还是0,所以,j 的值为0。

原子性:分时复用引起
  • 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    转账问题:A向B转账1000元,那么A的账户减去1000,B的账户增加1000元

有序性:重排序引起
  • 即程序执行的顺序按照代码的先后顺序执行。在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
  • 重排序三种类型
    • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
    • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

解决并发问题:JMM(Java内存模型)

JVM 按需禁用缓存和编译优化的方法

  • volatile、synchronized、final
  • Happens-Before 规则
    • 单一线程原则:在一个线程内,在程序前面的操作先行发生于后面的操作
    • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
    • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作
    • 线程启动规则:Thread 对象的 start()方法调用先行发生于此线程的每一个动作
    • 线程加入规则:Thread 对象的结束先行发生于 join() 方法返回
    • 线程中断规则:对线程 interrupt() 方法的调用先行大剩余被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生
    • 对象终结原则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
    • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C

线程安全:不是一个非真即假的命题

1. 概念:
  • 当多个线程同时访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,则称这个对象是线程安全的。
2. Java中的线程安全:
(1)不可变:
  • Java(JDK 5之后)不可变对象一定是线程安全的,不需要再进行任何线程安全保障措施。
  • 只要一个不可变(被final修饰)的对象被正确的构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远不变。
  • 不可变的类型:
    • 多线程共享的数据是一个基本数据类型,只需在定义时使用final关键字修饰就可保证其不可变;
    • 若共享数据是一个对象,需要对象自行保证其行为不会对其状态产生任何影响。如:java.lang.String 类的对象实例,用户调用其 substring()、replace()、contat()方法时都不会影响其原本的值,只会返回一个新构造的字符串对象。
    • 枚举类型
    • Number 部分子类,如 Long 和Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
(2)绝对线程安全:
  • 代价过于高昂。不管运行时环境如何,调用者都不需要任何额外的同步措施。
  • java.util.Vector 是一个线程安全的容器,其所有方法都被修饰成 synchronized,但并不意味着调用它的时候就不再需要同步手段。
(3)相对线程安全:
  • 通常所说的线程安全即为相对线程安全
  • 要求保证对这个对象单次的操作是线程安全的,在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续使用,可能需要在调用端使用额外的同步手段来保证调用的正确性。
  • 如:Vector()、HashTable、Connections 的 synchronizedCollection() 方法包装的集合等
(4)线程兼容:
  • 指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用。我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。
  • Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等
(5)线程对立:
  • 不管调用端是否采用了同步措施,都无法在多线程环境中并发使用代码。Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
3.线程安全的实现方法:
(1)互斥同步:
  • 最常见最主要的并发正确性保障手段
  • 同步即多个线程并发共享数据时,保证共享数据在同一时刻只能被一条(或者是一些,当使用信号量的时候)线程使用。互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。
  • synchronized:
    • 经过 javac编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令
    • Java 源码中的 synchronized 明确指定了对象参数,就以这个对象的引用作为 reference(用来指明要锁定和解锁的对象)
    • 如果没有明确指明,就根据 synchronized 修饰的方法类型(如实例方法或类方法),来决定取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁
    • 问题:
      • 被synchronized 修饰的同步块对同一条线程来说是可重入的。即:同一线程反复进入同步块也不会出现自己吧自己锁死的情况
      • 被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件的阻塞后面其他线程的进入。即:无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的进程中断等待或超时退出。
  • java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免的陷入用户态到核心态的转换中,需要耗费大量的处理器时间
  • java.util.concurrent(J.U.C)包(JDK5起)
    • java.util.concurrent.locks.Lock 接口是 Java 的另一种全新的互斥同步手段。基于 Lock 接口,用户能够以非块结构来实现互斥同步,在类库层面去实现同步,为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。
    • 重入锁(ReentrantLock):Lock接口最常见的一种实现,与 synchronized 一样可重入
      • 可重入性:一条线程可反复进入被它自己持有锁的同步块的特性,即锁关联的计数器,如果持有锁的线程再次获得它,则将计数器的值加一,每次释放锁时计数器的值减一,当计数器的值为零时,才能真正释放锁
      • 高级功能:
        1. 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
        2. 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 是非公平锁,ReentrantLock 在默认情况下为非公平锁,但可通过带布尔值的构造函数要求使用公平锁。
        3. 锁绑定多个条件:一个 ReentrantLock 对象可以同时绑定多个 Condition 对象。
          1. synchronized 中,锁对象的 wait() 和 notify() 或者 notifyAll() 方法配合可以实现一个隐含条件,若要和多于一个的条件关联的时候,需在添加一个锁。
          2. ReentrantLock只需多次调用 newCondition() 方法。
  • synchronized 和 ReentrantLock 都可满足需要时优先使用 synchronized:
    • synchronized 是在 Java 语法层面的同步,足够清晰也足够简单。在只需要基础的同步功能时,更推荐 synchronized
    • Lock 需要确保在 finally 块中释放锁,否则一旦受同步保护的代码块中抛出异常,可能永远不会释放持有的锁。synchronized 由Java 虚拟机来确保即使出现异常,锁也能被自动释放
    • Java 虚拟机更容易针对 synchronized 来进行优化,因为Java 虚拟机可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而是用 J.U.C 中的 Lock,Java 虚拟机很难得知具体哪些锁对象是由特定线程锁持有的

主要问题是:进行线程阻塞和唤醒所带来的性能开销,互斥同步也称为阻塞同步,是一种悲观的并发策略

(2)非阻塞同步:
  • 基于冲突检测的乐观并发策略。

    即:不管风险,先进行操作,若没有其他线程争用共享数据,操作直接成功;若共享数据被争用产生了冲突,进行其他的补救措施,最常用的是:不断重试,直到出现没有竞争的共享数据为止。

    乐观并发策略需要”硬件指令集的发展“,需要依靠硬件来实现”操作和冲突检测这两个步骤具有原子性“;常用指令:

    • 测试并设置(Test-and-Set)
    • 获取并增加(Fetch-and-Increment)
    • 交换(Swap)
    • 比较并交换(Compare-and-Swap)
    • 加载链接/条件存储(Load-Linked / Store-Conditional)
  • CAS指令:

    • 涉及的三个操作数:

      • 需要读写的内存值 V
      • 旧的预期值 A
      • 要写入的新值 B

      当且仅当 V 的值等于 A 时,CAS通过原子方式用新值 B 来更新 V 的值(”比较+更新“整体是一个原子操作)。一般情况下,”更新“的一个不断重试的操作。

    • 存在的问题:

      • ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但实际确实有变化的,

        解决思路:在变量前面添加版本号,每次变量更新的时候把版本号加一,变化过程变为:”1A-2B-3A“;

        Java中:JDK1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

        大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效

      • 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

      • 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步只是保障存在共享数据争用时正确性的手段,若一个方法本来就不涉及共享数据,则不需要同步措施保证其正确性,天生线程安全

(3)无同步方案:
  • 可重入代码:指可以在代码执行的任何时候中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错,也不会对结果有所影响。

    • 在特指多线程的上下文语境里(不涉及信号量等因素),可以认为可重入代码是线程安全代码的一个真子集,这意味着相对线程安全来说,可重入性是更为基础的特性,可以保证代码线程安全
    • 可重入代码的共同特征:不依赖全局变量、存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
  • 线程本地存储

    • 若一段代码中所需要的数据必须于其他代码共享,首先判断这些共享数据的代码能否保证在同一个线程中执行。若能,就可以把共享数据的可见范围限制在同一个县城之内,这样无需同步也能保证线程之间不出现数据争用的问题。

    • 应用实例:Web 交互模型中的 “一个请求对应一个服务器线程” (Thread-per-Request) 的处理方式,这种处理方式使得很多 Web服务器端应用都可以使用线程本地存储来解决线程安全问题

    • 可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能

      thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

      public class ThreadLocalExample {
          public static void main(String[] args) {
              ThreadLocal threadLocal = new ThreadLocal();
              Thread thread1 = new Thread(() -> {
                  threadLocal.set(1);
                  try {
                      Thread.sleep(1000);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  System.out.println(threadLocal.get());
                  threadLocal.remove();
              });
              Thread thread2 = new Thread(() -> {
                  threadLocal.set(2);
                  threadLocal.remove();
              });
              thread1.start();
              thread2.start();
          }
      }
      // 1
      
    • 底层结构图:

    • 每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象

    • 当调用一个 ThreadLocal 的 set(T value) / get() 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLocal->value 键值对插入到该 Map 中

      ThreadLocal.ThreadLocalMap threadLocals = null;
      
      public void set(T value) {
          Thread t = Thread.currentThread();
          ThreadLocalMap map = getMap(t);
          if (map != null)
              map.set(this, value);
          else
              createMap(t, value);
      }
      
      public T get() {
          Thread t = Thread.currentThread();
          ThreadLocalMap map = getMap(t);
          if (map != null) {
              ThreadLocalMap.Entry e = map.getEntry(this);
              if (e != null) {
                  @SuppressWarnings("unchecked")
                  T result = (T)e.value;
                  return result;
              }
          }
          return setInitialValue();
      }
      
      
  • 栈封闭

    • 多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的

      	public void add(){
              int cnt = 0;
              for (int i = 0; i < 100; i++) {
                  cnt++;
              }
              System.out.println(cnt);
          }
      
          public static void main(String[] args) {
              Main example = new Main();
              ExecutorService executorService = Executors.newCachedThreadPool();
              executorService.execute(() -> example.add());
              executorService.execute(() -> example.add());
              executorService.shutdown();
          }
      // 100
      // 100
      

    参考资料:
    《深入理解Java虚拟机》
    Java并发—理论基础

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值