JUC(15)线程安全1:如何保证线程安全

本文详细介绍了Java中的线程安全概念,包括不可变、绝对线程安全、相对线程安全、线程兼容和线程对立五种线程安全状态。同时,讲解了线程安全的实现方法,如互斥同步(阻塞同步)、非阻塞同步(乐观并发)和无同步方案,特别提到了可重入代码、栈封闭和线程本地存储。通过对这些概念和技术的理解,有助于开发者更好地处理多线程环境下的并发问题。
摘要由CSDN通过智能技术生成

一、什么是线程安全

线程安全: 它指的是在多线程环境中,当多个线程并发访问同一共享资源时,程序能够正确地执行而不产生数据不一致或其他错误的情况。

  • java中的线程安全,就是线程同步的意思,就是当一个程序(线程)对一个线程安全的方法或者语句进行访问的时候,其他的线程就不能再对他进行操作了,必须等到这次访问结束以后才能对这个线程安全的方法进行访问。

  • 线程同步就是指一个线程要等待上一个线程执行完之后才开始执行当前的线程。

  • 线程异步是指一个线程去执行,它的下一个线程不必等待它执行完就开始执行。

  • 线程安全的意义: 线程安全, 是指变量或方法( 这些变量或方法是多线程共享的) 可以在多线程的环境下被安全有效的访问。这说明了两方面的问题:

    • 可以从多个线程中调用, 无需调用方有任何操作;
    • 可以同时被多个线程调用, 无需线程之不必要的交互。

线程不安全会引起的问题:

  • 数据不一致(Data Inconsistency):当多个线程尝试同时修改同一数据时,如果没有适当的锁定机制,数据可能会被不同线程以不可预测的顺序修改,导致最终数据状态的不一致。
  • 竞态条件(Race Conditions):竞态条件发生在多个线程以特定的、不可预知的顺序执行时,导致程序的行为不符合预期。这种问题很难复现和调试,因为它们依赖于线程调度的细节。
  • 脏读(Dirty Reads):当一个线程读取到另一个线程尚未提交或正在进行修改的数据时,读取的数据可能是不完整的或过时的,这种情况称为脏读。
  • 死锁(Deadlocks):当两个或多个线程互相等待对方释放锁时,它们都将无限期地阻塞,从而导致整个系统挂起。死锁是一种严重的问题,需要精心设计同步机制来避免。
  • 活锁(Livelocks):活锁类似于死锁,但线程仍在运行,只是在重复无效的操作,无法取得进展。
  • 饥饿(Starvation):当一个线程总是被优先级更高的线程抢占,从而永远无法获得CPU时间或锁资源,导致它无法继续执行。

二、线程安全状态(等级)

  • 线程安全问题,从题目来看,显然有点大。从小系统到各分布式系统,其复杂程度可以想象,随之线程安全的抽象性不言而喻。总之就为了下个定义:一个类在可以被多个线程安全调用时就是线程安全的。
  • 并且线程安全不是一个“非真即假”,“非黑即白”的单项选择题,按照“线程安全”的安全程度由强到弱来排序,我们可以将java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

在这里插入图片描述

2.1不可变

  • 不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施,只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。不可变对象上没有副作用, 并且缓存不可变对象的引用总是安全的。一个不可变的对象的一个引用可以自由共享,而不用担心被引用的对象要被修改。

  • 不可变的类型:

    • final 关键字修饰的基本数据类型;
    • String
    • 枚举类型
    • Java 类库中大多数基本数值类如Integer、BigInteger 和 BigDecimal 都是原子性的,但Long 和 Double 就不能保证其操作的原子性, 可在声明变量的时候用volatile 关键字。
  • 多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

2.2绝对线程安全

  • 绝对的线程安全完全满足Brian GoetZ给出的线程安全的定义,这个定义其实是很严格的,一个类要达到 “不管运行时环境如何,调用者都不需要任何额外的同步措施” 通常需要付出很大的代价。

  • 百科上这样解释:线程安全性类的对象操作序列( 读或写其公有字段以及调用其公有方法) 都不会使该对象处于无效状态, 即任何操作都不会违反该类的任何不可变量、前置条件或者后置条件。

2.3相对线程安全

  • 相对线程安全就是我们通常意义上所讲的一个类是“线程安全”的。

  • 它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

  • 在java语言中,大部分的线程安全类都属于相对线程安全的,例如Vector、HashTable、Collections的synchronizedCollection()方法保证的集合。

  • 案例:下面的代码,如果删除元素的线程删除了一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 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)
*/

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

executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.remove(i);
        }
    }
});
executorService.execute(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.get(i);
        }
    }
});

2.4线程兼容

  • 线程兼容就是我们通常意义上所讲的一个类不是线程安全的。

  • 线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境下可以安全地使用。Java API中大部分的类都是属于线程兼容的。如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。

2.5线程对立

  • 线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

  • 一个线程对立的例子是Thread类的supend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都有死锁风险。正因此如此,这两个方法已经被废弃啦。

三、线程安全的实现方法

3.1互斥同步(阻塞同步)(悲观并发)

  • 互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的

  • 互斥同步的手段(方法):

    • 在java中,最基本的互斥同步手段就是synchronized关键字(就是同步方法和同步代码块),synchronized关键字编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码质量,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
    • 此外,ReentrantLock也是通过互斥来实现同步。在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性。(ReentrantLock使用起来就是“加锁lock()”和“解锁unlock()”)
  • 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

3.2非阻塞同步 (乐观并发)

  • 随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

  • 乐观锁(乐观并发)需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。

    • CAS指令需要有3个操作数,分别是内存地址(在java中理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V处的值符合旧预期值A时,处理器用B更新V处的值,否则它就不执行更新,但是无论是否更新了V处的值,都会返回V的旧值,上述的处理过程是一个原子操作。
    • CAS缺点:
      • ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
      • ABA问题的解决思路就是使用版本号。在变量前面追加版本号,每次变量更新的时候把版本号加一,那么A-B-A就变成了1A-2B-3C。JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
    • 关于CAS这些东西,可以参考我的另一篇博客JUC(高并发)中第21和22条https://blog.csdn.net/wang_luwei/article/details/108937154

3.3无同步方案

  • 要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。
3.3.1可重入代码(ReentrantCode)
  • 这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

  • 可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

  • (类比:synchronized拥有锁重入的功能,也就是在使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时时可以再次得到该对象的锁)

3.3.2栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在栈中,属于线程私有的。

package com.wlw.pool;

public class StackClosedExample {
    public void add100(){
        int num = 0;
        for (int i = 0; i < 100; i++) {
            num++;
        }
        System.out.println(num);
    }
}
package com.wlw.pool;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TestStackClosed {
    public static void main(String[] args) {
        StackClosedExample stackClosedExample = new StackClosedExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(()->{stackClosedExample.add100();});
        executorService.execute(()->{stackClosedExample.add100();});
        executorService.shutdown();
    }
}
/*
100
100
*/
3.3.3线程本地存储(Thread Local Storage)
  • 如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
  • 符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
  • 可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。
  • 例如,对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。
public class ThreadLocalExample {
    public static void main(String[] args) {
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}
  • ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构存在导致 ThreadLocal 有内存泄漏的情况,所以尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。

  • 最佳场景是这样的:ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景

  • 关于ThreadLocal,可以参考我的另一篇博客:人手一支笔:ThreadLocal 实现原理与源码详解

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

悬浮海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值