线程安全的实现方法

根据一个Java全栈知识的网站来整理,在整理大致思路的基础上补充自己的理解或延伸
线程安全是Java开发中特别重要的一块,因为Java本身多线程的特性,所以不加处理的话并发会导致一些问题

这篇文章讲得很好:https://zhuanlan.zhihu.com/p/35382932

多线程下的线程为什么不安全

  1. 操作的原子性被破坏
    是指一个或多个操作在CPU执行的过程中被中断。
    类似数据库事务,这个操作要么全部执行,要么全部不执行,否则就称为原子性被破坏
    一个很经典的例子就是银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B最后的数额不是操作前的数额+1000
    Java中天然的原子操作有:基本数据类型的复制操作(除了long和double)、reference的赋值操作、JUC包下的操作
  2. 不能保证变量的可见性
    可见性定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
    举例:
//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行i=10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中的值变为10了,却没有立即写入到主存当中。
此时线程2执行j= i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得的值为0,而不是10
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值

  1. 编译器优化导致有序性被破坏
    有序性定义:即程序执行的顺序按照代码的先后顺序执行
    Java内部的编译器有两种,分别是静态编译器和动态编译器,前者能将.java文件编译成.class文件,后者能将.class文件编译成机器码,之后再由JVM运行。问题就出在动态编译器上,它为了程序的整体性能会进行指令重排序,虽然能提升程序的性能,但会导致源代码中指定的内存访问顺序与实际的执行顺序不同

重排序分三种类型: 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术 (Instruction-Level
ParallelismILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

单线程下,指令重排序会考虑指令之间的数据依赖性,从而保证程序最终结果和代码顺序相同,但多线程下就会出现线程不安全问题。

线程安全定义

什么是线程不安全?从定义来看,就是多个线程对同一个共享数据进行访问,不进行同步处理的情况下,最终的操作结果与预想的是不一样的。放到业务场景中,比如在秒杀场景下,秒杀商品出现超卖,在实际业务中是不可能被容忍的
所以线程安全的定义如下:在多线程环境下,每次运行结果和单线程运行的结果是一样的,其他变量也和预期的是一样的

线程安全的程度划分

根据共享数据的安全程度的强弱顺序分为以下5类:

  1. 不可变
    不可变的对象因为不会被修改,故不会出现前后不一致的问题,无论在单线程还是多线程下都不需要考虑线程安全

不可变的类型:
final 关键字修饰的基本数据类型 String 枚举类型 Number 部分子类,如 Long 和 Double
等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和
AtomicLong 则是可变的。 对于集合类型,可以使用 Collections.unmodifiableXXX()
方法来获取一个不可变的集合。

  1. 绝对线程安全
    不管运行时环境如何,调用者都不需要任何额外的同步措施。

  2. 相对线程安全
    相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
    Java中的大部分线程安全类都是相对线程安全的

  3. 线程兼容
    指对象本身并不是线程安全,但可以通过在代码中使用正确的同步手段来保证在并发环境下可以安全使用。
    Java API中的大部分都是,比如ArrayList和HashMap这些

  4. 线程对立
    指无论是否采取了同步方法,都无法在多线程中并发使用的代码
    Java天然就是多线程的,这个很少出现

如何实现线程安全?

理清了线程不安全的出现因素,针对这三个特性,自然就有了解决方法

1、互斥同步

Java提供了两种锁机制来控制多个线程对共享资源的访问

  • JVM的synchronized
  • JDK实现的ReentrantLock
    这两种锁机制可以保证操作中的原子性和可见性问题
synchronized

使用的时候要注意:一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;无论方法正常执行还是抛出异常,synchronized都会释放锁
用法有下面这几种:
需要把握的是每种用法的锁到底是什么,代码在哪个部分只能被一个线程访问

  • 同步代码块
public void func() {
    synchronized (this) {
        // ...
    }
}

当使用synchronized修饰代码块时,充当锁的是对象实例,用以下这段代码实例来理解
有一个示例类

public class SynchronizedExample {
    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}

写一个主方法来调用它

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e1.func1());
}

我们都知道this对象的含义,谁调用了方法,谁就充当了this对象,所以这里的e1就是锁

Every object has an intrinsic lock associated with it. —— The Java™ Tutorials
指出Java中每个对象都有一个内置锁,其实也就是this

对于上述代码,使用ExecutorService执行了两个线程,这两个线程执行的都是e1的代码块,因此这两个线程处于同步执行的状态,一个线程进入的时候,另一个必须等待

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

但如果线程调用的是不同的对象的同步代码块,输出的效果是线程交替执行的

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}

0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9
  • 同步方法
    使用synchronized修饰非静态方法时,用的也是调用该方法的实例的内置锁this,和同步代码块是一样的,都是作用于对象实例

  • 同步类/同步静态方法
    同步一个类

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

同步静态方法

public synchronized static void fun() {
    // ...
}

把这两个放在一块说是因为它们都是作用于整个类,多个线程执行时只能有一个线程进入该类

ReentrantLock

是 java.util.concurrent(J.U.C)包中的锁。相比起来是采用的显式加锁的方式,加锁位置也更灵活

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }
}
public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}

也可实现线程同步

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

2、非阻塞同步

互斥同步的方法百分百的保证了线程安全问题,可是并不高效,因为它频繁的线程切换会导致大量的性能开销,所以被称为阻塞同步。这是一种悲观的并发策略,每次不管有没有线程与其竞争,都会上锁。
(一)CAS
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将V 的值更新为 B。
(二)Atomiclnteger
J.U.C 包里面的整数原子类 Atomiclnteger,其中的 compareAndSet0 和 getAndIncrementl等方法都使用了 Unsafe 类的 CAS 操作。
(三)ABA
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效

3、无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
(一)栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
(二)线程本地存储(Thread Local Storage)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值