Skip to main content

到底什么是线程安全和线程不安全?

moremind...About 12 minJava-ThreadJava-Thread

1.什么是线程安全?

维基百科给出的定义如下:

线程安全是程式设计中的术语,指某个函数、函数库多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。

在《Java并发编程实战》一书中给出如下定义:

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这只和对象在程序中是以何种方式被使用的有关,和对象本身具体是做什么的无关。

在《深入Java虚拟机》一书中给出如下定义:

当多个线程访问同一个对象时,**如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,**调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

线程安全:在多线程同时访问一个资源时,线程间依照某种方式访问资源时,访问的结果总是能获取到正确的结果。

2.Java内存模型-JMM

16bf62d8c174d776
16bf62d8c174d776

上图描述了一个多线程执行场景。 线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量共享变量,也就是说此变量只此一份,多个线程间共享。但是线程不能直接读写主内存的共享变量,每个线程都有自己的工作内存,线程需要读写主内存的共享变量时需要先将该变量拷贝一份副本到自己的工作内存,然后在自己的工作内存中对该变量进行所有操作,线程工作内存对变量副本完成操作之后需要将结果同步至主内存。

线程的工作内存是线程私有内存,线程间无法互相访问对方的工作内存。

3.共享变量(共享资源)

所谓共享变量,指的是多个线程都可以操作的变量。进程是分配资源的基本单位,线程是执行的基本单位。所以,多个线程之间是可以共享一部分进程中的数据的。在JVM中,Java堆和方法区的区域是多个线程共享的数据区域。也就是说,多个线程可以操作保存在堆或者方法区中的同一个数据。那么,保存在堆和方法区中的变量就是Java中的共享变量。

那么,Java中哪些变量是存放在堆中,哪些变量是存放在方法区中,又有哪些变量是存放在栈中的呢?

Java中共有三种变量,分别是类变量、成员变量和局部变量。他们分别存放在JVM的方法区、堆内存和栈内存中。(栈内存是程序线程独占空间)

public class Variables {

    /**
     * 类变量
     */
    private static int a;

    /**
     * 成员变量
     */
    private int b;

    /**
     * 局部变量
     * @param c
     */
    public void test(int c){
        int d;
    }
}

上面定义的三个变量中,变量a就是类变量,变量b就是成员变量,而变量c和d是局部变量。

所以,变量a和b是共享变量,变量c和d是非共享变量。所以如果遇到多线程场景,对于变量a和b的操作是需要考虑线程安全的,而对于线程c和d的操作是不需要考虑线程安全的。

4.线程安全的实现

4.1 无状态实现

在大多数情况下,多线程应用中的错误是错误地在多个线程之间共享状态的结果。

因此,我们要研究的第一种方法是 使用无状态实现来实现线程安全。

为了更好地理解这种方法,让我们考虑一个带有静态方法的简单工具类,该方法可以计算数字的阶乘:

public class MathUtils {
    
    public static BigInteger factorial(int number) {
        BigInteger f = new BigInteger("1");
        for (int i = 2; i <= number; i++) {
            f = f.multiply(BigInteger.valueOf(i));
        }
        return f;
    }
}

factorial方法是一种无状态确定性函数。 确定性是指:给定特定的输入,它将始终产生相同的输出。

该方法既不依赖外部状态,也不维护自身的状态。因此,它被认为是线程安全的,并且可以同时被多个线程安全地调用。

所有线程都可以安全地调用 factorial 方法,并且将获得预期结果,而不会互相干扰,也不会更改该方法为其他线程生成的输出。

因此,无状态实现是实现线程安全的最简单方法

4.2 不可变的实现

如果我们需要在不同线程之间共享状态,则可以通过使它们成为不可变对象来创建线程安全类

不变性是一个功能强大,与语言无关的概念,在Java中相当容易实现。

当类实例的内部状态在构造之后无法修改时,它是不可变的

在Java中创建不可变类的最简单方法是声明所有字段为 privatefinal ,且不提供 setter:

public class MessageService {
    
    private final String message;
 
    public MessageService(String message) {
        this.message = message;
    }
    
    // 标准 getter
    
}

一个 MessageService 对象实际上是不可变的,因为它的状态在构造之后不能更改。因此,它是线程安全的。

此外,如果 MessageService 实际上是可变的,但是多个线程仅对其具有只读访问权限,那么它也是线程安全的。

因此,不变性是实现线程安全的另一种方法

4.3 线程私有 (ThreadLocal) 字段

在面向对象编程(OOP)中,对象实际上需要通过字段维护状态并通过一种或多种方法来实现行为。

如果我们确实需要维护状态,则可以通过使它们的字段成为线程局部的来创建不在线程之间共享状态的线程安全类。

通过简单地在 Threadopen in new window 类中定义私有字段,我们可以轻松创建其字段为线程局部的类。

例如,我们可以定义一个存储整数数组的 Thread 类:

public class ThreadA extends Thread {
    
    private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
    
    @Override
    public void run() {
        numbers.forEach(System.out::println);
    }
}

而另一个类可能拥有一个字符串数组:

public class ThreadB extends Thread {
    
    private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");
    
    @Override
    public void run() {
        letters.forEach(System.out::println);
    }
}

在这两种实现中,这些类都有其自己的状态,但是不与其他线程共享。因此,这些类是线程安全的。

同样,我们可以通过将 ThreadLocalopen in new window 实例分配给一个字段来创建线程私有字段。

例如,让我们考虑以下 StateHolder 类:

public class StateHolder {
    
    private final String state;
 
    // 标准的构造函数和 getter
}

我们可以很容易地使其成为线程局部(ThreadLocal)变量,如下所示:

public class ThreadState {
    
    public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {
        
        @Override
        protected StateHolder initialValue() {
            return new StateHolder("active");  
        }
    };
 
    public static StateHolder getState() {
        return statePerThread.get();
    }
}

线程局部字段与普通类字段非常相似,不同之处在于,每个通过setter / getter访问它们的线程都将获得该字段的独立初始化副本,以便每个线程都有自己的状态。

4.4 同步集合类

通过使用collections框架open in new window 中包含的一组同步包装器,我们可以轻松地创建线程安全的collectionsopen in new window

例如,我们可以使用以下同步包装之一open in new window来创建线程安全的集合:

Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();

让我们记住,同步集合在每种方法中都使用内在锁定(我们将在后面介绍内在锁定)。

这意味着该方法一次只能由一个线程访问,而其他线程将被阻塞,直到该方法被第一个线程解锁。

因此,由于同步访问的基本逻辑,同步会对性能造成不利影响。

4.5 支持并发的集合

除了同步集合,我们可以使用并发集合来创建线程安全的集合。

Java提供了 java.util.concurrentopen in new window 包,其中包含多个并发集合,例如 ConcurrentHashMapopen in new window

Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");

与同步对象不同,并发集合通过将其数据划分为段来实现线程安全。例如,在 ConcurrentHashMap 中,多个线程可以获取不同 Map 段上的锁,因此多个线程可以同时访问 Map

由于并发线程访问的先天优势,并发集合类具备远超同步集合类更好的性能

值得一提的是,同步集合和并发集合仅使集合本身具有线程安全性,而不使content变得线程安全

4.6 原子化对象

使用Java提供的一组原子类open in new window(包括 AtomicIntegeropen in new windowAtomicLongopen in new windowAtomicBooleanopen in new windowAtomicReferenceopen in new window )也可以实现线程安全。

原子类使我们能够执行安全的原子操作,而无需使用同步。原子操作在单个机器级别的操作中执行。

要了解解决的问题,让我们看下面的 Counter 类:

public class Counter {
    
    private int counter = 0;
    
    public void incrementCounter() {
        counter += 1;
    }
    
    public int getCounter() {
        return counter;
    }
}

让我们假设在竞争条件下,两个线程同时访问 increasingCounter() 方法。

从理论上讲, counter 字段的最终值为2。但是我们不确定结果如何,因为线程在同一时间执行同一代码块,并且增量不是原子的。

让我们使用 AtomicInteger 对象创建 Counter 类的线程安全实现:

public class AtomicCounter {
    
    private final AtomicInteger counter = new AtomicInteger();
    
    public void incrementCounter() {
        counter.incrementAndGet();
    }
    
    public int getCounter() {
        return counter.get();
    }
}

这是线程安全的,因为在++增量执行多个操作的同时, 增量和获取 是原子的

4.7 同步方法

尽管较早的方法对于集合和基元非常有用,但有时我们需要的控制权要强于此。

因此,可用于实现线程安全的另一种常见方法是实现同步方法。

简而言之,一次只能有一个线程可以访问同步方法,同时阻止其他线程对该方法的访问。其他线程将保持阻塞状态,直到第一个线程完成或该方法引发异常。

我们可以通过使它成为同步方法,以另一种方式创建线程安全版本的 creationCounter()

public synchronized void incrementCounter() {
    counter += 1;
}

我们通过与前缀的方法签名创建一个同步方法 synchronized 关键字。

由于一次一个线程可以访问一个同步方法,因此一个线程将执行 crementCounter() 方法,而其他线程将执行相同的操作。任何重叠的执行都不会发生。

同步方法依赖于“内部锁”或“监视器锁”的使用。固有锁是与特定类实例关联的隐式内部实体。

在多线程上下文中,术语 monitor 是指对关联对象执行锁的角色,因为它强制对一组指定的方法或语句进行排他访问。

当线程调用同步方法时,它将获取内部锁。线程完成执行方法后,它将释放锁,从而允许其他线程获取锁并获得对方法的访问。

我们可以在实例方法,静态方法和语句(已同步的语句)中实现同步。

4.8 同步语句

有时,如果我们只需要使方法的一部分成为线程安全的,那么同步整个方法可能就显得过分了。

为了说明这个用例,让我们重构 increascountCounter 方法:

public void incrementCounter() {
    // 此处可有额外不需同步的操作
    // ...
    synchronized(this) {
        counter += 1; 
    }
}

该示例很简单,但是它显示了如何创建同步语句。假设该方法现在执行了一些不需要同步的附加操作,我们仅通过将相关的状态修改部分包装在一个同步块中来对其进行同步

与同步方法不同,同步语句必须指定提供内部锁的对象,通常是this引用。

同步非常昂贵,因此使用此选项,我们尽可能只同步方法的相关部分

4.8 其他对象作为锁

我们可以通过将另一个对象用作监视器锁定,来稍微改善 Counter 类 的线程安全实现。

这不仅可以在多线程环境中提供对共享资源的协调访问,还可以使用外部实体来强制对资源进行独占访问

public class ObjectLockCounter {
 
    private int counter = 0;
    private final Object lock = new Object();
    
    public void incrementCounter() {
        synchronized(lock) {
            counter += 1;
        }
    }
    
    // 标准 getter
}

我们使用一个普通的 Object 实例来强制相互排斥。此实现稍好一些,因为它可以提高锁定级别的安全性。

将 this 用于内部锁定时,攻击者可能会通过获取内部锁定并触发拒绝服务(DoS)条件来导致死锁。

相反,在使用其他对象时, 无法从外部访问该私有实体。这使得攻击者更难获得锁定并导致死锁。

5.参考文章

1.什么是线程安全?.https://www.jianshu.com/p/44831d1d10d3open in new window

2.深入理解Java并发编程(一):到底什么是线程安全.https://www.hollischuang.com/archives/3060open in new window

3.Java 并发基础——线程安全性.https://www.cnblogs.com/NeilZhang/p/8682266.htmlopen in new window

4.什么是线程安全以及如何实现?https://segmentfault.com/a/1190000023187634open in new window

5.你真的知道什么是线程安全吗?.https://www.hoohack.me/2020/09/01/what-is-thread-safeopen in new window

6.(解释的比较清楚,透彻的)图解 Java 线程安全.https://juejin.cn/post/6844903890224152584open in new window

7.Java进阶(二)当我们说线程安全时,到底在说什么.http://www.jasongj.com/java/thread_safe/open in new window

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.8