Java 核心知识体系 8:Java 如何保证线程安全性
1 Java 内存模型(JMM) 如何解决并发问题
维度 1:使用关键字、属性进行优化 JMM 本质实际就是:Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。这些方法包括了:
volatile、synchronized 和 final 关键字
Happens-Before 规则
维度 2:从 顺序一致性、可见性、有序性、原子性角度
顺序一致性
一个线程中的所有操作按照程序的顺序执行,不受其他线程的影响。
原子性
Java 程序中,对数据的读和写操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行,否则会产生问题。通过下面的案例可以看出,哪些是原子操作,哪些是非原子操作:
从上面的案例中可以看中,只有第一个例子才是具备原子性的,因为他只有一个存的动作。至于其他的例子,包含读取、操作、赋值等多个动作,有一个动作失败则不成立。所以,基本读取和赋值,Java 内存模型可以保证原子性操作,如果要实现更大范围、步骤更多的操作的原子性,则需要通过 synchronized 或者 Lock 来实现。synchronized 和 Lock 的存在是为了够保证任一时刻只有一个线程能够执行该代码块,这样也就解决了原子性。
可见性
Java 提供了 volatile 关键字来保证可见性,使用 volatile 来修饰共享变量,可以保证修改的值立即更新到主存中。这样其他线程读取数据时,始终都会从内存中读取到新值。而普通的共享变量不能保证可见性,因为修改之后,不确定什么时候被写入主存,当其他 Thread 去读取时,内存中很有可能还是原来的旧值,所以无法保证可见性。另外,通过 synchronized 关键字和 Lock 功能也能够保证可见性,因为能限制同一时刻只有一个线程获取锁然后执行同步代码,且在释放之前会将变量的修改更新到主存中。所以实时可见。
有序性
在 Java 里面,可以通过 volatile 关键字来保证一定的“有序性”。另外,通过 synchronized 关键字和 Lock 功能也能够保证可见性,因为能限制同一时刻只有一个线程获取锁然后执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。注:JMM 是通过 Happens-Before 规则来保证 Thread 操作有序性。
2.1 关键字: volatile、synchronized 和 final
在 Java 中,volatile、synchronized 和 final 是三个非常重要的关键字,它们都与并发编程密切相关。下面是对这三个关键字的详细介绍:
2.1.1 volatile
volatile 是 Java 中的一种修饰符,它用于声明一个共享变量,以确保多个线程对该变量的访问是可见的和有序的。volatile 关键字的作用是禁止指令重排和强制刷新缓存,以保证操作的顺序性和可见性。当一个变量被声明为 volatile 时,它表示该变量的值可能会被意想不到地改变。编译器和处理器会注意到这个变量的特殊性,并采取相应的措施来保证多个线程对该变量的访问是正确的。具体来说,volatile 关键字会禁止编译器对 volatile 变量进行优化,每次读取该变量时都会直接从它的内存地址中读取,而不是从寄存器或缓存中读取。同时,volatile 关键字也会强制处理器在每个操作该变量的指令之后立即刷新缓存,以保证其他线程能够看到最新的值。需要注意的是,虽然 volatile 关键字可以保证可见性和有序性,但它并不能保证原子性。也就是说,如果一个操作包含多个步骤,而这些步骤不能被一个指令替换,那么这个操作就不能被保证为原子性。在这种情况下,需要使用锁或者其他同步机制来保证原子性。
2.1.2 synchronized
synchronized 是 Java 中的一种关键字,它用于实现同步代码块和方法。synchronized 关键字可以保证同一时刻只有一个线程能够执行被 synchronized 修饰的代码块或方法。synchronized 关键字会创建一个锁对象或锁标识符,当一个线程获取了这个锁对象或锁标识符后,其他线程就不能再获取这个锁对象或锁标识符,直到第一个线程释放了这个锁对象或锁标识符。synchronized 关键字可以保证多个线程对共享变量的访问是互斥的,也就是说在同一时刻只有一个线程能够访问共享变量。这样可以避免多个线程同时修改共享变量而导致数据不一致的问题。同时,synchronized 关键字还可以保证多个线程之间的操作是有序的,即一个线程在执行 synchronized 代码块或方法之前必须等待其他线程完成之前的操作。需要注意的是,synchronized 关键字虽然可以保证互斥性和有序性,但它并不能保证原子性。也就是说,如果一个操作包含多个步骤,而这些步骤不能被一个指令替换,那么这个操作就不能被保证为原子性。在这种情况下,需要使用其他同步机制来保证原子性。
2.1.3 final
final 是 Java 中的一种修饰符,它用于声明一个最终变量或方法。final 关键字表示该变量或方法不能被修改或重写。具体来说,final 关键字可以用于声明一个常量,该常量的值不能被修改;也可以用于声明一个方法,该方法不能被重写。final 关键字在并发编程中也有着重要的作用。final 关键字可以保证一个共享变量的值只被一个线程修改,这样可以避免多个线程同时修改共享变量而导致数据不一致的问题。同时,final 关键字还可以保证一个方法的执行不会被其他线程中断或干扰,这样可以保证方法的原子性和可见性。需要注意的是,final 关键字并不能保证多个线程之间的操作是有序的。也就是说,在一个线程中执行 final 方法时,其他线程可能会同时执行自己的操作,而这些操作之间是没有顺序关系的。在这种情况下,需要使用其他同步机制来保证操作的顺序性。
2.2 Happens-Before 规则
上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,在 JVM 中还有 Happens-Before 规则,用来确定并发操作之间的顺序关系。Happens-Before 规则定义了以下几种顺序关系:
2.2.1 程序顺序规则(Program Order Rule)
在一个程序中,按照代码的顺序,先执行的操作 Happens-Before 后执行的操作。这意味着在程序中,如果一个操作先于另一个操作执行,那么这个操作的结果对后续操作是可见的。
2.2.2 管程锁定规则(Monitor Lock Rule)
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
2.2.3 volatile 变量规则(Volatile Variable Rule)
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,先写后读。
2.2.4 线程启动规则(Thread Start Rule)
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
2.2.5 线程加入规则((Thread Join Rule)
Thread 对象的结束先行发生于 join() 方法返回。
2.2.6 线程终止规则(Thread Termination Rule)
线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法和 Thread.isAlive()的返回值等手段检测线程是否已经终止执行
2.2.7 线程中断规则( Thread Interruption Rule)
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
2.2.8 对象终结规则(Finalizer Rule)
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
2.2.9 传递性(Transitivity)
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
3 线程安全性能讨论
在多线程环境中,一个类或者一个函数不管在何种运行时环境或交替执行方式,都能保证正确的行为,被安全的调用,就说明线程是安全的。这个“正确的行为”通常包括原子性、可见性和有序性。但是线程安全不是非真即假,共享数据按照安全程度的强弱顺序可以分成以下五类:
不可变
绝对线程安全
相对线程安全
线程兼容
线程对立
按照线程安全性的强弱顺序,不可变 > 绝对线程安全 > 相对线程安全 > 线程兼容 > 线程对立。
3.1 不可变(Immutable)
不可变的对象在创建后其状态就不能被修改,因此它们自然是线程安全的。任何线程在任何时候访问这些对象,都会看到相同的数据。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。不可变的类型包括:
final 关键字修饰的基本数据类型
String
枚举类型
Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的
对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
XXX 可以是 Map、List、Set
执行时抛出异常
不可变状态还可以这么理解,外部无法对数据状态进行修改,比如
在这个例子中,ImmutableClass 是不可变的,因为它的构造函数是私有的,外部无法修改其状态。因此,多个线程同时访问和获取 ImmutableClass 对象的值时,不会出现数据不一致的问题。
3.2 绝对线程安全(Absolute Thread Safety)
绝对线程安全的对象无论运行时环境如何,调用者都不需要任何额外的同步措施。这通常需要付出较大的代价来实现。
在这个例子中,ThreadSafeClass 的每个方法都使用了 synchronized 关键字进行同步。这保证了无论多少个线程同时访问 ThreadSafeClass 的对象,每个线程的操作都会被串行执行,不会出现数据竞争的问题。
3.3 相对线程安全(Relative Thread Safety)
相对线程安全的对象需要保证单个操作是线程安全的,在调用的时候不需要做额外的保障措施。但在连续调用时可能需要额外的同步措施来保证调用的正确性。Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。以 Hashtable 为例,因为它的每个方法都是同步的。但是,如果多个线程连续调用 Hashtable 的不同方法(如 put 和 get),仍然可能出现竞态条件。为了避免这种情况,调用者需要在外部进行额外的同步。
在下面代码中,如果 Vector 中的一个元素被线程 A 删除,而线程 B 试图获取一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。
如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。
3.4 线程兼容(Thread Compatibility)
线程兼容的对象本身不是线程安全的,但可以通过在调用端添加额外的同步措施来保证在多线程环境下的安全使用。Java API 中大部分的类都是属于线程兼容的,比如 ArrayList 类就不是线程安全的。如果多个线程同时修改 ArrayList,可能会导致数据不一致。但是,如果调用者在修改 ArrayList 时使用 synchronized 块或其他同步机制进行同步,就可以保证线程安全。
在这个例子中,ThreadCompatibleClass 的方法没有使用 synchronized 关键字进行同步。因此,如果多个线程同时修改 ThreadCompatibleClass 的对象,可能会导致数据不一致。
3.5 线程对立(Thread Hostility)
线程对立的对象无论如何都无法在多线程环境下并发使用,即使采取了同步措施。一个典型的例子是 Java 中的 ThreadLocalRandom 类。这个类用于生成随机数,并且每个线程都有其自己的随机数生成器实例。由于每个线程使用不同的实例,因此无需担心线程安全问题。但是,如果尝试在没有正确初始化 ThreadLocalRandom 的情况下跨线程使用它,就可能导致问题。
这种情况下,即使添加了同步措施也无法保证线程安全。
4 如何实现线程安全
4.1 synchronized 关键字/ReentrantLock 特性
synchronized 关键字
在 Java 中,synchronized 关键字是一种内置的同步机制,用于控制多个线程对共享资源的访问。它用于在并发环境中保护代码块,确保同一时刻只有一个线程可以执行该代码块。synchronized 关键字可以应用于方法或代码块。当它应用于方法时,它将锁住该方法的对象。当它应用于代码块时,它将锁住指定的锁对象。
上面这个例子中,incrementCount()方法使用了 synchronized 关键字。这意味着在任何时刻,只有一个线程可以执行该方法。如果有其他线程试图同时执行该方法,它们将会被阻塞,直到当前线程完成该方法的执行。
ReentrantLock 特性
ReentrantLock 是 Java 中的一个可重入锁,它是一种比 synchronized 关键字更灵活的线程同步机制。ReentrantLock 允许一个线程多次获取同一个锁,而不会产生死锁。它也支持公平锁和非公平锁,可以根据实际需求进行选择。下面是一个使用 ReentrantLock 的示例:
在上面的这个例子中,我们定义了一个 ReentrantLock 和一个计数器 count。incrementCount() 方法使用 lock.lock() 获取锁,然后增加计数器的值,最后使用 lock.unlock() 释放锁。getCount() 方法直接返回计数器的值,无需获取锁。这种方式比使用 synchronized 关键字更灵活,因为它可以细粒度地控制需要同步的代码块,而不是整个方法。
★ 后续的章节会详细的介绍 synchronized 关键字和 ReentrantLock 特性,敬请期待
4.2 非阻塞同步
在 JAVA 中,互斥同步最主要的问题就是线程阻塞和唤醒所带来的开销导致的性能问题,这种同步也称为阻塞同步,是一种悲观的并发策略,无论共享数据是否真的会出现竞争,它都要进行加锁,这样 用户态核心态转换、维护锁计数器和阻塞检查、线程唤醒等操作都会产生大量的开销。非阻塞同步是指在多线程环境下,不需要使用阻塞等待的方式来实现同步控制,线程可以一直进行计算操作,而不会被阻塞。下面介绍几种手段实现非阻塞同步。
CAS 随着硬件指令集水平的发展,我们经常使用基于冲突检测的乐观并发策略: 先执行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(始终重试,直至成功)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,则将内存位置的值更新为 B,否则不进行任何操作。在并发环境中,CAS 操作可以保证数据的一致性和线程安全性。
AtomicInteger
AtomicInteger 是 Java 中的一个原子整数类,它提供了原子操作的更新方法,可以在多线程环境下安全地更新共享的整数变量。AtomicInteger 的更新方法包括 incrementAndGet()、getAndIncrement()、decrementAndGet()、getAndDecrement()、compareAndSet()等,它们使用了 Unsafe 类的 CAS 操作,保证对共享变量的操作是原子性的。
以下代码使用了 AtomicInteger 执行了计数操作。
在这个示例中,我们使用 AtomicInteger 来维护一个计数器的值,并启动了 10 个线程,每个线程将计数器加 10 次。由于 AtomicInteger 提供了原子操作的更新方法,因此即使多个线程同时更新计数器的值,也不会出现线程安全问题。最后,我们输出计数器的值,可以看到它应该是 100(10 个线程每个线程执行 10 次计数器加 1 操作)。
ABA
如果某个线程将变量 A 更改为 B 后再更改为 A,那么另一个等待 CAS 操作的线程会认为该变量没有发生过改变,仍然是 A,然后执行 CAS 操作。这样就可能导致数据的不一致。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
另外,Java 8 引入了一种新的原子类:LongAdder 和 LongAccumulator,它们内部采用了分段化的思想来解决高并发下的 ABA 问题。它们将内部变量分为一个数组,每个线程更新自己的分段,最后再合并结果。这种方式既解决了 ABA 问题,又提高了并发性能。
4.3 无同步方案
换一个思路,如果没有方法的计算不涉及共享数据,不需要进行同步,是不是就不需要任何同步措施去保证正确性,也就没有线程安全的问题。
栈封闭:多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
可重入代码(Reentrant Code):可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
这块简单介绍,后续会有专门的章节进行学习
5 总结
了解了多线程产生的原因,以及线程不安全的原因
从 可见性,原子性和有序性 来阐述并发状态下线程不安全的原因
分析了 Java 是怎么解决并发问题的
文章转载自:Hello-Brand
评论