美文网首页
Java 线程安全特性与问题

Java 线程安全特性与问题

作者: Drew_MyINTYRE | 来源:发表于2022-04-25 21:13 被阅读0次

    如果你的代码在单线程下或者在多线程下执行都能获得一样的结果,那么你的代码就是线程安全的。当进行多线程编程时,我们又会面临哪些线程安全的要求呢?又该如何去解决的呢?

    有序性

    有序性指的是,程序执行的顺序按照代码的先后顺序执行。以下面这段代码为例:

    boolean started = false; // 语句1
    long counter = 0L; // 语句2
    counter = 1; // 语句3
    started = true; // 语句4
    

    从代码顺序上看,上面四条语句应该依次执行,但实际上 JVM 真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

    处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

    讲到这里,有人要着急了——什么,CPU 不按照我的代码顺序执行代码,那怎么保证得到我们想要的结果呢?实际上,大家大可放心,CPU 虽然并不保证完全按照代码顺序执行,但它会 保证程序最终的执行结果和代码顺序执行时的结果一致。

    死锁

    指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

    关于死锁发生的条件:

    • 如果一个线程占用了某资源,那么其他线程必须处于等待状态,直到该资源被释放。

    • 线程 T1 至少已经保持了对一个资源 R1 的占用,但又提出对另一个资源 R2 的请求,而此时,资源 R2 被另外一个线程 T2 占用,于是该线程 T1 也必须等待,但又对自己保持的资源 R1 不释放。

    • 线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。

    • 最直观的理解是,p0 等待 p1 占用的资源,而 p1 而在等待 p0 占用的资源,于是两个进程就相互等待

    活锁

    线程1可以使用资源,但它很礼貌,让其他线程先使用资源,线程2 也可以使用资源,但它很绅士,也让其他线程先使用资源。这样你让我,我让你,最后两个线程都无法使用资源。

    马路中间有条小桥,只能容纳一辆车经过,桥两头开来两辆车A和B,A比较礼貌,示意B先过,B也比较礼貌,示意A先过,结果两人一直谦让谁也过不去。

    饥饿

    如果一个线程因为 CPU 时间全部被其他线程抢走而得不到 CPU 运行时间,这种状态被称之为 饥饿。而该线程被 饥饿致死 正是因为它得不到 CPU 运行时间的机会。(例如:高优先级线程会吞噬掉所有的低优先级线程的 CPU 时间。)

    公平

    Java 不可能实现 100% 的公平性,依然可以通过同步结构在线程间实现公平性的提高。

    首先来学习一段简单的同步态代码:

    public class Synchronizer{
        public synchronized void doSynchronized () {
            // do a lot of work which takes a long time
        }
    }
    

    如果有多个线程调用 doSynchronized() 方法,在第一个获得访问的线程未完成前,其他线程将一直处于阻塞状态,而且在这种多线程被阻塞的场景下,接下来将是哪个线程获得访问是没有保障的。为了提高等待线程的公平性,改为 使用锁方式替代同步块。

    public class Synchronizer{
        Lock lock = new Lock();
        public void doSynchronized() throws InterruptedException{
            this.lock.lock();
            // Critical section, do a lot of work which takes a long time
            this.lock.unlock();
        }
    }
    

    常用的保证 Java 操作原子性的工具是 锁和同步方法(或者同步代码块)。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。

    public void testLock () {
        synchronized (anyObject){
            int j = i;
            i = j + 1;
        }
    }
    

    CAS(compare and swap)

    基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际它不是原子操作。Java 中提供了对应的 原子操作类 来实现该操作,并保证原子性,其本质是利用了 CPU 级别的 CAS 指令。由于是 CPU 级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger 使用方法如下:

    AtomicInteger atomicInteger = new AtomicInteger();
    for(int b = 0; b < numThreads; b++) {
        new Thread(() -> {
            for(int a = 0; a < iteration; a++) {
                atomicInteger.incrementAndGet();
            }
        }).start();
    }
    

    如何确保可见性?

    Java 提供了 volatile 关键字来保证可见性。当使用 volatile 修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它线程对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。volatile 适用于不需要保证原子性,但却需要保证可见性的场景。

    关于线程安全的几个为什么?

    1,平时项目中使用锁和 synchronized 比较多,而很少使用 volatile,锁和 synchronized 保证可见性吗?

    锁和 synchronized 即可以保证原子性,也可以保证可见性。都是通过保证同一时间只有一个线程执行目标代码段来实现的。

    2,锁和 synchronized 为何能保证可见性?

    根据 JDK 7的Java doc 中对concurrent包的说明,一个线程的写结果保证对另外线程的读操作可见,只要该写操作可以由 happen-before 原则推断出在读操作之前发生。

    3,既然锁和 synchronized 可保证原子性也可保证可见性,为何还需要volatile

    synchronized 和锁需要通过 操作系统 来仲裁谁获得锁,开销比较高,而volatile 开销小很多。因此在只需要保证可见性的条件下,使用 volatile 的性能要比使用锁和 synchronized 高得多。

    4,既然锁和 synchronized 可以保证原子性,为什么还需要 AtomicInteger 这种的类来保证原子操作?

    锁和 synchronized 需要通过 操作系统 来仲裁谁获得锁,开销比较高,而AtomicInteger 是通过 CPU 级的 CAS 操作来保证原子性,开销比较小。所以使用 AtomicInteger 的目的还是为了提高性能。

    相关文章

      网友评论

          本文标题:Java 线程安全特性与问题

          本文链接:https://www.haomeiwen.com/subject/kxlqyrtx.html