Java核心知识体系8:Java如何保证线程安全性

Java核心知识体系1:泛型机制详解
Java核心知识体系2:注解机制详解
Java核心知识体系3:异常机制详解
Java核心知识体系4:AOP原理和切面应用
Java核心知识体系5:反射机制详解
Java核心知识体系6:集合框架详解
Java核心知识体系7:线程不安全分析

1 Java内存模型(JMM) 如何解决并发问题

维度1:使用关键字、属性进行优化
JMM本质实际就是:Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。这些方法包括了:

  • volatile、synchronized 和 final 关键字
  • Happens-Before 规则

维度2:从 顺序一致性、可见性、有序性、原子性角度

  • 顺序一致性

一个线程中的所有操作按照程序的顺序执行,不受其他线程的影响。

  • 原子性

Java程序中,对数据的读和写操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行,否则会产生问题。
通过下面的案例可以看出,哪些是原子操作,哪些是非原子操作:

// 1个动作,线程直接将值赋给idx,也就是直接写到内存中
idx = 100

// 3个动作:先定义 jdx,再读取idx的值,最后赋值给jdx
jdx := idx

// 3个动作:读取jdx的值,进行加1操作,然后新值重新写入新的值
jdx ++

从上面的案例中可以看中,只有第一个例子才是具备原子性的,因为他只有一个存的动作。至于其他的例子,包含读取、操作、赋值等多个动作,有一个动作失败则不成立。
所以,基本读取和赋值,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后执行的操作。这意味着在程序中,如果一个操作先于另一个操作执行,那么这个操作的结果对后续操作是可见的。

image

2.2.2 管程锁定规则(Monitor Lock Rule)

一个unlock操作先行发生于后面对同一个锁的lock操作。
image

2.2.3 volatile变量规则(Volatile Variable Rule)

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,先写后读。
image

2.2.4 线程启动规则(Thread Start Rule)

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
image

2.2.5 线程加入规则((Thread Join Rule)

Thread 对象的结束先行发生于 join() 方法返回。
image

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

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

执行时抛出异常

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$testUnmodifiable.put(Collections.java:1523)
    at ImmutableExample.main(ImmutableClass.java:9)

不可变状态还可以这么理解,外部无法对数据状态进行修改,比如

public class ImmutableClass {  
    private final int value;  
  
    public ImmutableClass(int value) {  
        this.value = value;  
    }  
  
    public int getValue() {  
        return value;  
    }  
}

在这个例子中,ImmutableClass是不可变的,因为它的构造函数是私有的,外部无法修改其状态。因此,多个线程同时访问和获取ImmutableClass对象的值时,不会出现数据不一致的问题。

3.2 绝对线程安全(Absolute Thread Safety)

绝对线程安全的对象无论运行时环境如何,调用者都不需要任何额外的同步措施。这通常需要付出较大的代价来实现。

public class ThreadSafeClass {  
    private int value;  
  
    public synchronized void setValue(int value) {  
        this.value = value;  
    }  
  
    public synchronized int getValue() {  
        return value;  
    }  
}

在这个例子中,ThreadSafeClass的每个方法都使用了synchronized关键字进行同步。这保证了无论多少个线程同时访问ThreadSafeClass的对象,每个线程的操作都会被串行执行,不会出现数据竞争的问题。

3.3 相对线程安全(Relative Thread Safety)

相对线程安全的对象需要保证单个操作是线程安全的,在调用的时候不需要做额外的保障措施。但在连续调用时可能需要额外的同步措施来保证调用的正确性。
Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
以Hashtable为例,因为它的每个方法都是同步的。但是,如果多个线程连续调用Hashtable的不同方法(如put和get),仍然可能出现竞态条件。为了避免这种情况,调用者需要在外部进行额外的同步。

在下面代码中,如果Vector中的一个元素被线程A删除,而线程B试图获取一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。

public class VectorUnsafeExample {
    private static Vector<Integer> vector = new Vector<>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 100; i++) {
                vector.add(i);
            }
            ExecutorService executorService = Executors.newCachedThreadPool();
            executorService.execute(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            });
            executorService.execute(() -> {
                for (int i = 0; i < vector.size(); i++) {
                    vector.get(i);
                }
            });
            executorService.shutdown();
        }
    }
}
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
    at java.util.Vector.remove(Vector.java:831)
    at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
    at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)

如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。

# 独立线程A执行删除操作
executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.remove(i);
        }
    }
});
# 独立线程B执行读取操作
executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.get(i);
        }
    }
});

3.4 线程兼容(Thread Compatibility)

线程兼容的对象本身不是线程安全的,但可以通过在调用端添加额外的同步措施来保证在多线程环境下的安全使用。
Java API 中大部分的类都是属于线程兼容的,比如ArrayList类就不是线程安全的。如果多个线程同时修改ArrayList,可能会导致数据不一致。但是,如果调用者在修改ArrayList时使用synchronized块或其他同步机制进行同步,就可以保证线程安全。

public class ThreadCompatibleClass {  
    private int value;  
  
    public void setValue(int value) {  
        this.value = value;  
    }  
  
    public int getValue() {  
        return value;  
    }  
}

在这个例子中,ThreadCompatibleClass的方法没有使用synchronized关键字进行同步。因此,如果多个线程同时修改ThreadCompatibleClass的对象,可能会导致数据不一致。

3.5 线程对立(Thread Hostility)

线程对立的对象无论如何都无法在多线程环境下并发使用,即使采取了同步措施。
一个典型的例子是Java中的ThreadLocalRandom类。这个类用于生成随机数,并且每个线程都有其自己的随机数生成器实例。由于每个线程使用不同的实例,因此无需担心线程安全问题。但是,如果尝试在没有正确初始化ThreadLocalRandom的情况下跨线程使用它,就可能导致问题。
这种情况下,即使添加了同步措施也无法保证线程安全。

4 如何实现线程安全

4.1 synchronized关键字/ReentrantLock特性

  • synchronized关键字

在Java中,synchronized关键字是一种内置的同步机制,用于控制多个线程对共享资源的访问。它用于在并发环境中保护代码块,确保同一时刻只有一个线程可以执行该代码块。
synchronized关键字可以应用于方法或代码块。当它应用于方法时,它将锁住该方法的对象。当它应用于代码块时,它将锁住指定的锁对象。

public class SynchronizedExample {  
    private int count = 0;  
  
    public synchronized void incrementCount() {  
        count++;  
    }  
}

上面这个例子中,incrementCount()方法使用了synchronized关键字。这意味着在任何时刻,只有一个线程可以执行该方法。如果有其他线程试图同时执行该方法,它们将会被阻塞,直到当前线程完成该方法的执行。

  • ReentrantLock特性

ReentrantLock 是 Java 中的一个可重入锁,它是一种比 synchronized 关键字更灵活的线程同步机制。ReentrantLock 允许一个线程多次获取同一个锁,而不会产生死锁。它也支持公平锁和非公平锁,可以根据实际需求进行选择。
下面是一个使用 ReentrantLock 的示例:

import java.util.concurrent.locks.ReentrantLock;  
  
public class ReentrantLockExample {  
    private final ReentrantLock lock = new ReentrantLock();  
    private int count = 0;  
  
    public void incrementCount() {  
        lock.lock();  
        try {  
            count++;  
        } finally {  
            lock.unlock();  
        }  
    }  
  
    public int getCount() {  
        return count;  
    }  
}

在上面的这个例子中,我们定义了一个 ReentrantLock 和一个计数器 count。
incrementCount() 方法使用 lock.lock() 获取锁,然后增加计数器的值,最后使用 lock.unlock() 释放锁。
getCount() 方法直接返回计数器的值,无需获取锁。
这种方式比使用 synchronized 关键字更灵活,因为它可以细粒度地控制需要同步的代码块,而不是整个方法。

★ 后续的章节会详细的介绍 synchronized关键字和ReentrantLock特性,敬请期待

4.2 非阻塞同步

在JAVA中,互斥同步最主要的问题就是线程阻塞和唤醒所带来的开销导致的性能问题,这种同步也称为阻塞同步,是一种悲观的并发策略,无论共享数据是否真的会出现竞争,它都要进行加锁,
这样 用户态核心态转换、维护锁计数器和阻塞检查、线程唤醒等操作都会产生大量的开销。
非阻塞同步是指在多线程环境下,不需要使用阻塞等待的方式来实现同步控制,线程可以一直进行计算操作,而不会被阻塞。下面介绍几种手段实现非阻塞同步。

  1. CAS
    随着硬件指令集水平的发展,我们经常使用基于冲突检测的乐观并发策略: 先执行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(始终重试,直至成功)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
    乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。
    CAS操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置V的值与预期原值A相匹配,则将内存位置的值更新为B,否则不进行任何操作。在并发环境中,CAS操作可以保证数据的一致性和线程安全性。

  2. AtomicInteger

AtomicInteger是Java中的一个原子整数类,它提供了原子操作的更新方法,可以在多线程环境下安全地更新共享的整数变量。
AtomicInteger的更新方法包括incrementAndGet()、getAndIncrement()、decrementAndGet()、getAndDecrement()、compareAndSet()等,它们使用了 Unsafe 类的 CAS 操作,保证对共享变量的操作是原子性的。

以下代码使用了 AtomicInteger 执行了计数操作。

import java.util.concurrent.atomic.AtomicInteger;  
  
public class AtomicIntegerExample {  
    private static AtomicInteger counter = new AtomicInteger(0);  
  
    public static void main(String[] args) {  
        // 启动10个线程,每个线程将计数器加10  
        for (int i = 0; i < 10; i++) {  
            new Thread(() -> {  
                for (int j = 0; j < 10; j++) {  
                    counter.incrementAndGet();  
                }  
            }).start();  
        }  
  
        // 等待所有线程执行完毕  
        try {  
            Thread.sleep(1000);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
  
        // 输出计数器的值  
        System.out.println("Counter: " + counter);  
    }  
}

在这个示例中,我们使用AtomicInteger来维护一个计数器的值,并启动了10个线程,每个线程将计数器加10次。由于AtomicInteger提供了原子操作的更新方法,因此即使多个线程同时更新计数器的值,也不会出现线程安全问题。最后,我们输出计数器的值,可以看到它应该是100(10个线程每个线程执行10次计数器加1操作)。

  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是怎么解决并发问题的
posted @ 2023-12-15 14:52  Hello-Brand  阅读(1370)  评论(0编辑  收藏  举报