当前位置 博文首页 > RtxTitanV的博客:Java并发总结之线程安全性

    RtxTitanV的博客:Java并发总结之线程安全性

    作者:[db:作者] 时间:2021-07-07 10:06

    本文主要参考《Java并发编程实战》对线程安全性的相关概念进行简单总结。

    一、线程安全性定义

    1.基本概念

    对象的状态:对象的状态是指存储在状态变量(例如实例域和静态域)中的数据。对象的状态可能包括其他依赖对象的域。对象的状态中包含了任何可能影响其外部可见行为的数据。

    共享:共享意味着变量可以被多个线程访问。

    可变:可变意味着变量的值在其生命周期内可以发生变化。

    2.产生线程安全问题的前提条件

    多线程环境中存在共享可变的状态变量。一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对该变量的访问。

    同步机制synchronizedvolatile变量,显式锁,原子变量。

    3.解决线程安全问题的方式

    如果当多个线程访问同一个可变的状态变量时没有使用适合的同步,那么程序就会出现错误。有3种方式可以修复这个问题:

    • 不在线程之间共享该状态变量。
    • 将状态变量修改为不可变变量。
    • 在访问状态变量时使用同步(加了同步后状态变量依然共享,同步只不过是协同了多线程并发访问同一个可变状态变量)。

    4.线程安全性定义

    在线程安全性的定义中,最核心概念是正确性。正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象状态,以及定义各种后验条件(Postcondition)来描述对象操作结果。当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。

    注意:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这写线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。

    5.线程安全类(对象)

    • 由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定不是线程安全的。
    • 无状态(不包含任何域以及任何其他类的域的引用,即不存在状态变量)对象一定是线程安全的。
    • 在无状态的类中添加一个状态时,该状态完全由线程安全的对象来管理,这个类仍然是线程安全的。
    • 在Java语言中(特指JDK 1.5以后,即Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最简单和最纯粹的。
    • 如果共享状态变量是一个基本数据类型,在定义时使用final关键字修饰就可以保证它是不可变的。如果共享状态变量是一个对象引用,那就需要保证它所引用对象的行为不会对其状态产生任何影响。

    常见的不可变类型:java.lang.String类,枚举类型,java.lang.Number的部分子类,如LongDouble等数值包装类型,BigIntegerBigDecimal等大数据类型;同为Number的子类型的原子类AtomicIntegerAtomicLong则并非不可变的。对于集合类型,可以使用Collections.unmodifiableXXX()方法来获取一个不可变的集合,例如:

    public class ImmutableExample {
        public static void main(String[] args) {
            Map<String, Integer> map = new HashMap<>();
            Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
            unmodifiableMap.put("a", 1);
        }
    }
    

    上面的代码获得了一个不可变的Map集合,调用put()方法会抛出一个UnsupportedOperationException异常。Collections.unmodifiableXXX()先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常,例如:

    public V put(K key, V value) {
        throw new UnsupportedOperationException();
    }
    

    二、原子性(Atomicity)

    1.竞态条件

    竞态条件的定义:在并发编程中由于不恰当的执行时序而产生不正确的结果。

    竞态条件的产生:某个计算结果的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。

    竞态条件的类型

    • 先检查后执行(Check-Then-Act),其本质就是基于一种可能失效的观察结果来做出判断或执行某个计算。
    • 读取-修改-写入,基于对象之前的状态来定义对象状态的转换。

    竞态条件并不总是会产生错误,还需要某种不恰当的执行时序,竞态条件也可能会导致严重问题。

    解决竞态条件问题:在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而保证其他线程只能在修改操作完成之前或之后读取和修改状态,而不是修改过程中。

    为确保线程安全性,先检查后执行,读取-修改-写入等操作必须是原子的。

    2.复合操作

    原子操作:两个操作A和B,对于执行A的线程,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指对于访问同一个状态的所有操作(包括该操作本身),该操作从其执行线程以外的任意线程来看是不可分割的,这个操作是一个以原子方式执行的操作。相应的我们就称该操作是具有原子性的。

    该定义有三层含义

    1. 原子操作是对于多线程而言的,对于单一线程,无所谓原子性。
    2. 原子操作是针对共享变量的。
    3. 原子操作是不可分割的。

    原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store 和write,我们大致可以认为对long和double类型以外基本数据类型的访问读写是具备原子性的。

    如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorentermonitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是synchronized关键字,因此在synchronized块之间的操作也具备原子性。

    在Java语言中,long和double类型以外的任何类型的变量的写操作都是原子操作。所以对于基本类型和引用类型的写操作(不是指初始化)都是本身具有原子性的。

    对于long和double类型变量的特殊规则:Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、 store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定(Nonatomic Treatment of double and long Variables)。

    如果在多线程环境中共享一个没有被volatile修饰的可变long或double类型的变量时,并且对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

    复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。加锁(内置锁)是确保原子性的内置机制。

    保证原子性的方式

    • 使用synchronized关键字。
    • 使用ReentrantLockjava.until.concurrent.locks包下)。
    • 使用AtomicIntergerjava.util.concurrent.atomic包下)。

    原子变量类简介java.util.concurrent.atomic包中有很多原子变量类用于实现数值和对象引用上的原子状态转换。

    要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

    三、内置锁

    Java提供了一种内置的锁机制来支持原子性:同步代码块(synchronized block)

    1.同步代码块

    同步代码包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。

    关键字synchronized修饰的方法为同步方法,是一种横跨整个方法体的同步代码块,该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法的锁就是代表方法所在的类的Class对象。

    2.内置锁

    每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法。

    Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁.如果B永远不释放锁,那么A也永远等待下去。

    由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义:一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

    3.重入

    什么是重入:当某个线程试图获得一个由其他线程持有的锁时,发出请求的线程就会被阻塞。如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功,这个线程不会被阻塞。重入意味着获取锁的操作的粒度是线程,而不是调用。

    重入的一种实现方法:为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。如果没有重入,某个线程试图获得一个已经由它自己持有的锁时将会产生死锁,重入可以避免这种死锁。

    内置锁是可重入锁

    四、锁保护状态

    由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

    如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁

    对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。每个共享和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

    对象的内置锁与其状态之间没有内在关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象

    一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护

    当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。即对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护

    cs
    下一篇:没有了