JVM之线程安全、原子性实现

本文深入探讨了JVM中的线程安全概念,包括线程安全的定义、状态依赖、线程安全的五种级别。文章强调了不可变对象在多线程环境中的重要性,并介绍了实现线程安全的同步方法,如阻塞同步、非阻塞同步以及无同步方案,特别讨论了Java原子操作的实现和CAS(Compare and Swap)算法及其存在的ABA问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.定义线程安全

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

这里是大牛Brian Goetz的文章

文字是拗口点,毕竟是外包给中国人翻译的,不过从英文思维看,就好理解了。

类要成为线程安全的,首先必须在单线程环境中有正确的行为。如果一个类实现正确(这是说它符合规格说明的另一种方式),那么没有一种对这个类的对象的操作序列(读或者写公共字段以及调用公共方法)可以让对象处于无效状态,观察到对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。

此外,一个类要成为线程安全的,在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排或者交错,它必须仍然有如上所述的正确行为,并且在调用的代码中没有任何额外的同步。其效果就是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。

正确性与线程安全性之间的关系非常类似于在描述 ACID(原子性、一致性、独立性和持久性)事务时使用的一致性与独立性之间的关系:从特定线程的角度看,由不同线程所执行的对象操作是先后(虽然顺序不定)而不是并行执行的。

1.2 状态依赖

看代码。尽管 Vector的所有方法都是同步的,但是在多线程的环境中不做额外的同步,就使用这段代码仍然是不安全的。因为如果另一个线程恰好在错误的时间里删除了一个元素,则 get() 会抛出一个ArrayIndexOutOfBoundsException

调get()的先决条件是以 size()的结果要合法,即大于0,vector里要有元素才能get 。这种以一个方法的结果作为另一个方法的输入条件的模式,它就是一个状态依赖,在多线程情况下,必须保证至少在调用这两种方法期间,元素的状态没有改变。

private static Vector<Integer> vector = new Vector<>();

public static void main(String[] args) throws ClassNotFoundException {
    while(true) {
        for (int i = 0; i < 10; i++) {
        vector.add(i);
    }

    Thread rm = new Thread(() -> {
        for (int i = 0; i < vector.size(); i++) {
        //依赖size()方法,确保size不变
            vector.remove(i);
    }
    });

    Thread pr = new Thread(() -> {
        for (int i = 0; i < vector.size(); i++) {
            //依赖size()方法,确保size不变
            System.out.println(vector.get(i));
        }
    });

    rm.start();
    pr.start();

    while (Thread.activeCount() > 20);

    }     
}

//-------------lock-------------------------------- 

Thread rm = new Thread(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
        // 依赖size()方法,确保size不变
        vector.remove(i);
        }
    }
});

Thread pr = new Thread(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
        // 依赖size()方法,确保size不变
                System.out.println(vector.get(i));
        }
    }
}); 

2. 线程的安全度

Brian Goetz在IBM developWorkers的文章,把线程安全分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

2.1 不可变(Immutable)

在Java语言里,不可变的对象一定是线程安全的,只要一个不可变的对象被正确构建出来,而且没有发生this引用逃逸,那其外部的可见状态永远也不会改变,永远也不会在多个线程中处于不一致的状态。

之前说volatile时,也提过,final可以保证可见性。final变量被初始化后,就不能再次赋值,其值存在方法区的常量池里。

java.lang.String就是典型不可变对象,调用其方法,返回的是新字符串对象,不会影响原来的值。

AtomicInteger,AtomicLong不是final修饰的类,原子类利用了硬件指令,下文会说

2.2 绝对线程安全

线程安全的对象,具有以下属性:

类的使用规则所规定的约束,在对象被多个线程访问时,仍然有效,不管运行时环境如何排列,线程都不需要任何额外的同步。

这种线程安全性保证是很严格的,许多类,如 Hashtable 或者 Vector 都不能满足这种严格的定义。

2.3 相对线程安全

这是我们通常意义上说的线程安全,需要保证,对这个对象单独的操作,是线程安全的,调用时,不需要做额外的保障措施。

但是对一些特定顺序的连续调用,如有依赖关系,就可能需要调用时,使用额外的同步手段。

Java大部分的线程安全类都属于这种类型,如Vector,HashTable,Collections的sychronizedCollection()方法包装的集合等

2.4 线程兼容

我们平常说一个类不是线程安全的,绝大多数指的,是这一种情况。

线程兼容,指对象本身不是线程安全的,但可以在调用时,正确地使用同步手段,保证对象在并发环境下的正确性。

Java API大部分的类属于这种,如ArrayList,HashMap

2.5 线程对立

线程对立类是指,不论调用时,是否采取同步措施,都无法在多线程环境中并发使用。

线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的类的行为,这时通常会出现线程对立。线程对立类的一个例子是调用System.setOut(),System.setIn(),System.runFinalizersOnExit(),Thread.suspend,Thread.resume()。

3.实现线程安全的方法

虚拟机提供的同步机制锁机制

  • 同步
    • 阻塞同步/互斥同步
    • 非阻塞同步

3.1 同步

3.1.1 阻塞同步/互斥同步

互斥同步(MutualExclusion & Blocking Synchronization) 比较常见。是一种悲观的并发策略。

同步:多个线程并发访问共享数据时,保证共享数据在同一个时刻,只被一个线程使用(使用信号量时,是一些线程)

互斥,或者说,阻塞线程,让多余的线程等待,是实现同步的一种手段。

临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)都是主要的互斥实现方式。

互斥是因,同步是果;互斥是方法,同步是目的。

同步手段

  • synchronized
  • java.util.concurrent(J.U.C)的ReentrantLock
  • 无同步方案

(1)
synchronized关键字编译后,会在同步快前后形成monitorenter,monitorexit2条关键字节码指令。这两个字节码都需要一个reference类型的参数,指明加锁和解锁的对象

如果synchronized明确指定了对象参数,那么这个对象就是Reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去获取对应的对象实例或Class对象作为锁对象。

在执行monitorenter指令时,首先尝试获取对象的锁。

  • 如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1;当执行monitorexit指令时将锁计数器-1。当计数器为0时,锁就被释放了。

  • 如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

之前说过,Java的线程实现,是映射到操作系统的原生线程上的,用户态和内核态的切换,耗费CPU很多时间。

(2)

除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步。ReentrantLock比synchronized增加了高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件

  • 等待可中断:当持有锁的线程,长期不释放锁时,正在等待的线程,可以选择放弃等待,对处理执行时间非常长的同步块很有用。

  • 公平锁: 多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized中的锁是非公平的。ReentrantLock默认非公平锁。

  • 绑定多个条件:可以同时绑定多个Condition对象。

synReentrank

3.1.2 非阻塞同步

非阻塞同步(Non-Blocking Synchronization),乐观的并发策略。

互斥同步/阻塞同步最主要的问题,是线程阻塞和唤醒带来的性能问题。

策略上讲,这是一种悲观并发策略,认为不做正确的同步措施,肯定出问题。无论是否共享数据是否真的出现竞争,它都要进行加锁(实际上,虚拟机会优化掉大部分的不必要的锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。

非阻塞同步,依赖硬件指令,基于冲突检测,其许多实现都不需要挂起线程。

先进行操作,如果没有其他线程争用数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再进行其他的
补偿措施,常见的有不断重试,直到成功。

Java原子操作的实现

我们需要操作和冲突检测,这两个步骤具备原子性

靠什么保证呢?互斥同步吗,那非阻塞同步就没意义了。

—>靠硬件,保证一个从语义上,看起来需要多次操作的行为,只通过一条处理器指令就完成,有:

    1. 测试并设置(Test and Set)
    1. 获取并增加(Fetch and Increment)
    1. 交换(Swap)
    1. 比较并交换(Compare and Swap,传说中的CAS)
    1. 加载链接/条件存储(Load Linked/Store Contional, LL/SC)

前3条,是上世纪已经在CPU指令集,后面2条,是现代CPU新增的。

//极其重要的指令

CAS指令,需要3个操作数

内存位置(Java中,可以简单理解为变量的内存地址,V)
旧的预期值(A)
新值(B)

while(CAS执行时 and V != A):

    if(当且仅当V,符合旧预期值A时):
        V = B(CPU用新值B更新V)
        break
    else
        (否则不执行更新)
    (但不论是否更新V,都返回V的旧值)


注意上述文字。上面的处理过程,是一个原子操作

JDK 1.5后,才可以用CAS,原子类的源码显示,其实现也用到了CAS。该操作由sun.misc.Unsafe类的compareAndSwapInt()compareAndSwapLong()compareAndSwapObject()等几个方法包装提供。

虚拟机内部,對这些方法做了特殊处理,JIT出来的结果,是一条平台相关的CAS指令,没有调用过程,或者,可以认为无条件内联进去了。

J.U.C原子类,comapreAndSet(),getAndIncrement()调用了Unsafe类的CAS操作。

public final int incrementAndGet() {
   /* for(;;) {
        int current = get();
        int next = current + 1;
        //这里调用了CAS
        if (compareAndSet(current, next)) {
            return next;
        }
    }
    */
//Java 8
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

//字节码
// Method descriptor #232 (Ljava/lang/Object;JI)I
  // Stack: 7, Locals: 6
  public final int getAndAddInt(java.lang.Object arg0, long arg1, int arg2);
     0  aload_0 [this]
     1  aload_1 [arg0]
     2  lload_2 [arg1]  //复制局部变量到操作数栈
     3  invokevirtual sun.misc.Unsafe.getIntVolatile(java.lang.Object, long) : int [359]
     6  istore 5
     8  aload_0 [this]
     9  aload_1 [arg0]
    10  lload_2 [arg1]
    11  iload 5
    13  iload 5
    15  iload 4 [arg2]
    17  iadd
    18  invokevirtual sun.misc.Unsafe.compareAndSwapInt(java.lang.Object, long, int, int) : boolean [369] //CAS操作
    21  ifeq 0
    24  iload 5
    26  ireturn
    Stack map table: number of frames 1
        [pc: 0, same]

CAS存在的问题

逻辑漏洞
–> 变量V初次读取时为A,准备赋值时,检查到它仍为A,那么,任务变量V的值没有被其他线程修改过

但事实时,有可能V被该过,而刚好检查时,V刚好被该成了A。

这就是CAS的“ABA”问题

  • J.U.C提供AtomicStampedReference类,通过控制变量值的版本,保证CAS的正确性

  • 如需解决,传统的互斥同步可能更高效率

3.1.3 无同步方案

保证线程安全,不一定要同步。同步只是保证共享数据争用时的正确性手段。

  • 可重入代码(Reetrant Code)
    • 可在代码执行任何时刻中断,转而执行另一段代码,回来后执行原来的程序,不会出现任何错误
    • 不依赖存储在堆上的数据和公用的系统资源,用到的状态量由参数传入,不调用非可重入的方法
  • 线程本地存储(Thread Local Storage)
    • 把共享数据的可见范围限制在同一个线程内
    • 用java.lang.ThreadLocal类实现
    • 大部分使用消费队列的架构模式,都尽量在一个线程中消费完产品
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值