关于java:万字详解-Java-线程安全面试必备

44次阅读

共计 10710 个字符,预计需要花费 27 分钟才能阅读完成。

起源:blog.csdn.net/u014454538/article/details/98515807

1. Java 中的线程平安

  • Java 线程平安:广义地认为是多线程之间共享数据的拜访。
  • Java 语言中各种操作共享的数据有 5 种类型:不可变、相对线程平安、绝对线程平安、线程兼容、线程独立

① 不可变

  • 不可变(Immutable)的对象肯定是线程平安的,不须要再采取任何的线程平安保障措施。
  • 只有能正确构建一个不可变对象,该对象永远不会在多个线程之间呈现不统一的状态。
  • 多线程环境下,该当尽量使对象成为不可变,来满足线程平安。

如何实现不可变?

  • 如果共享数据是根本数据类型,应用 final 关键字对其进行润饰,就能够保障它是不可变的。
  • 如果共享数据是一个对象,要保障对象的行为不会对其状态产生任何影响。
  • String 是不可变的,对其进行 substring()、replace()、concat()等操作,返回的是新的 String 对象,原始的 String 对象的值不受影响。而如果对 StringBuffer 或者 StringBuilder 对象进行 substring()、replace()、append()等操作,间接对原对象的值进行扭转。
  • 要构建不可变对象,须要将外部状态变量定义为 final 类型。如 java.lang.Integer 类中将 value 定义为 final 类型。

Java 面试题最全整顿:https://www.javastack.cn/mst/

private final int value;

常见的不可变的类型:

  • final 关键字润饰的根本数据类型
  • 枚举类型、String 类型
  • 常见的包装类型:Short、Integer、Long、Float、Double、Byte、Character 等
  • 大数据类型:BigInteger、BigDecimal

留神:原子类 AtomicInteger 和 AtomicLong 则是可变的。

对于汇合类型,能够应用 Collections.unmodifiableXXX() 办法来获取一个不可变的汇合。

  • 通过 Collections.unmodifiableMap(map) 获的一个不可变的 Map 类型。
  • Collections.unmodifiableXXX() 先对原始的汇合进行拷贝,须要对汇合进行批改的办法都间接抛出异样。

例如,如果取得的不可变 map 对象进行 put()、remove()、clear()操作,则会抛出 UnsupportedOperationException 异样。

② 相对线程平安

相对线程平安的实现,通常须要付出很大的、甚至不切实际的代价。

Java API 中提供的线程平安,大多数都不是相对线程平安。

例如,对于数组汇合 Vector 的操作,如 get()、add()、remove()都是有 synchronized 关键字润饰。有时调用时也须要手动增加同步伎俩,保障多线程的平安。

上面的代码看似不须要同步,理论运行过程中会报错。

import java.util.Vector;

/**
 * @Author: lucy
 * @Version 1.0
 */
public class VectorTest {public static void main(String[] args) {Vector<Integer> vector = new Vector<>();
        while(true){for (int i = 0; i < 10; i++) {vector.add(i);
            }
            new Thread(new Runnable() {
                @Override
                public void run() {for (int i = 0; i < vector.size(); i++) {System.out.println("获取 vector 的第" + i + "个元素:" + vector.get(i));
                    }
                }
            }).start();
            new Thread(new Runnable() {
                @Override
                public void run() {for (int i=0;i<vector.size();i++){System.out.println("删除 vector 中的第" + i+"个元素");
                        vector.remove(i);
                    }
                }
            }).start();
            while (Thread.activeCount()>20)
                return;
        }
    }
}

呈现 ArrayIndexOutOfBoundsException 异样,起因:某个线程恰好删除了元素 i,使得以后线程无法访问元素 i。

Exception in thread "Thread-1109" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 1
 at java.util.Vector.remove(Vector.java:831)
 at VectorTest$2.run(VectorTest.java:28)
 at java.lang.Thread.run(Thread.java:745)

须要将对元素的 get 和 remove 结构成同步代码块:

synchronized (vector){for (int i = 0; i < vector.size(); i++) {System.out.println("获取 vector 的第" + i + "个元素:" + vector.get(i));
    }
}
synchronized (vector){for (int i=0;i<vector.size();i++){System.out.println("删除 vector 中的第" + i+"个元素");
        vector.remove(i);
    }
}

③ 绝对线程平安

  • 绝对线程平安须要保障对该对象的单个操作是线程平安的,在必要的时候能够应用同步措施实现线程平安。
  • 大部分的线程安全类都属于绝对线程平安,如 Java 容器中的 Vector、HashTable、通过 Collections.synchronizedXXX() 办法包装的汇合。

④ 线程兼容

  • Java 中大部分的类都是线程兼容的,通过增加同步措施,能够保障在多线程环境中平安应用这些类的对象。
  • 如常见的 ArrayList、HashTableMap 都是线程兼容的。

⑤ 线程对抗

  • 线程对抗是指:无奈通过增加同步措施,实现多线程中的平安应用。
  • 线程对抗的常见操作有:Thread 类的 suspend()和 resume()(曾经被 JDK 申明破除),System.setIn()System.setOut() 等。

2. Java 的枚举类型

通过 enum 关键字润饰的数据类型,叫枚举类型。

  • 枚举类型的每个元素都有本人的序号,通常从 0 开始编号。
  • 能够通过 values()办法遍历枚举类型,通过 name()或者 toString()获取枚举类型的名称
  • 通过 ordinal()办法获取枚举类型中元素的序号
public class EnumData {public static void main(String[] args) {for (Family family : Family.values()) {System.out.println(family.name() + ":" + family.ordinal());
        }
    }
}

enum Family {GRADMOTHER, GRANDFATHER, MOTHER, FATHER, DAUGHTER, SON;}

能够将枚举类型看做一般的 class,在外面定义 final 类型的成员变量,便能够为枚举类型中的元素赋初值。

要想获取枚举类型中元素理论值,须要为成员变量增加 getter 办法。

尽管枚举类型的元素有了本人的理论值,然而通过 ordinal()办法获取的元素序号不会产生扭转。

public class EnumData {public static void main(String[] args) {for (Family family : Family.values()) {System.out.println(family.name() + ":理论值" + family.getValue() +
                    ", 理论序号" + family.ordinal());
        }
    }
}
enum Family {GRADMOTHER(3), GRANDFATHER(4), MOTHER(1), FATHER(2), DAUGHTER(5), SON(6);
    private final int value;
    Family(int value) {this.value = value;}
    public int getValue() {return value;}
}

3. Java 线程平安的实现

① 互斥同步

互斥同步(Mutex Exclusion & Synchronization)是一种常见的并发正确性保障伎俩。

  • 同步:多个线程并发访问共享数据,保障共享数据同一时刻只被一个(或者一些,应用信号量)线程应用。
  • 互斥:互斥是实现同步的一种伎俩,次要的互斥实现形式:临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)。

同步与互斥的关系:

  • 互斥是起因,同步是后果。
  • 同步是目标,互斥是办法。

Java 中,最根本的实现互斥同步的伎俩是 synchronized 关键字,其次是 JUC 包中的 ReentrantLock。

对于 synchronized 关键字:

  • 编译后的同步块,开始处会增加 monitorenter 指令,完结处或异样处会增加 monitorexit 指令。
  • monitorenter 和 monitorexit 指令中都蕴含一个援用类型的参数,别离指向加锁或解锁的对象。如果是同步代码块,则为 synchronized 括号中明确指定的对象;如果为一般办法,则为以后实例对象;如果为静态方法,则为类对应的 class 对象。
  • JVM 执行 monitorenter 指令时,要先尝试获取锁:如果对象没被锁定或者以后线程曾经领有该对象的锁,则锁计数器加 1;否则获取锁失败,进入阻塞状态,期待持有锁的线程开释锁。
  • JVM 执行 monitorexit 指令时,锁计数器减 1,直到计数器的值为 0,锁被开释。(synchronized 是反对重进入的)
  • 因为阻塞或者唤醒线程都须要从用户态(User Mode)切换到外围态(Kernel Mode),有时锁只会被持有很短的工夫,没有必要进行状态转换。能够让线程在阻塞之前先自旋期待一段时间,超时未获取到锁才进入阻塞状态,这样能够防止频繁的切入到外围态。其实,就是前面自旋锁的思维。

对于 ReentrantLock:

  • 与 synchronized 关键字相比,它是 API 层面的互斥锁(lock()、unlock()、try…finally)。
  • 与 synchronized 关键字相比,具备可中断、反对偏心与非公平性、可绑定多个 Condition 对象的高级性能。
  • 因为 synchronized 关键字被优化,二者的性能差别并不是很大,如果不是想应用 ReentrantLock 的高级性能,优先思考应用 synchronized 关键字。

② 非阻塞同步

(1)CAS 概述

互斥同步最大的性能问题是线程的阻塞和唤醒,因而又叫阻塞同步。

互斥同步采纳乐观并发策略:

  • 多线程并发访问共享数据时,总是认为只有不加正确的同步措施,必定会呈现问题。
  • 无论共享数据是否存在竞争,都会执行加锁、用户态和心态的切换、保护锁计数器、查看是否有被阻塞的线程须要唤醒等操作。

随着硬件指令集的倒退,咱们能够采纳基于冲突检测的乐观并发策略:

  • 先进行操作,如果不存在抵触(即没有其余线程争用共享数据),则操作胜利。
  • 如果有其余线程争用共享数据,产生了抵触,应用其余的弥补措施。
  • 常见的弥补措施:一直尝试,直到胜利为止,比方循环的 CAS 操作。

乐观并发策略的许多实现都不须要将线程阻塞,这种同步操作叫做非阻塞同步。

非阻塞同步依附的硬件指令集:前三条是比拟长远的指令,后两条是古代处理器新增的。

  • 测试和设置(Test and Set)
  • 获取并减少(Fetch and Increment)
  • 替换(Swap)
  • 比拟并替换(Compare and Swap,即 CAS)
  • 加载链接 / 条件存储(Load Linked/ Store Conditional,即 LL/SC)

什么是 CAS?

  • CAS,即 Compare and Swap,须要借助处理器的 cmpxchg 指令实现。
  • CAS 指令须要三个操作数:内存地位 V(Java 中能够简略的了解为变量的内存地址)、旧的期待值 A、新值 B。
  • CAS 指令执行时,当且仅当 V 合乎旧的预期值 A,处理器才用新值 B 更新 V 的值;否则,不执行更新。
  • 不论是否更新 V 的值,都返回 V 的旧值,整个处理过程是一个原子操作。

原子操作:所谓的原子操作是指一个或一系列不可被中断的操作。

Java 中的 CAS 操作:

  • Java 中的 CAS 操作由 sun.misc.Unsafe 中的 compareAndSwapInt()、compareAndSwapLong()等几个办法包装提供。理论无奈调用这些办法,须要采纳反射机制能力应用。
  • 在理论的开发过程中,个别通过其余的 Java API 调用它们,如 JUC 包原子类中的 compareAndSet(expect, update)、getAndIncrement() 等办法。这些办法外部都应用了 Unsafe 类的 CAS 操作。
  • Unsafe 类的 CAS 操作,通过 JVM 的即时编译器编译后,是一条与平台相干的 CAS 指令。

除了偏差锁,Java 中其余锁的实现形式都是用了循环的 CAS 操作。

(2)通过循环的 CAS 实现原子操作

通过 ++ i 或者 i ++ 能够实现计数器的自增,在多线程环境下,这样应用是非线程平安的。

public class UnsafeCount {
    private int i = 0;
    private static final int THREADS_COUNT = 200;

    public static void main(String[] args) {Thread[] threads = new Thread[THREADS_COUNT];
        UnsafeCount counter = new UnsafeCount();
        for (int i = 0; i < THREADS_COUNT; i++) {threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {for (int j = 0; j < 10000; j++) {counter.count();
                    }
                }
            });
            threads[i].start();}
        while (Thread.activeCount() > 1) {Thread.yield();
        }
        System.out.println("多线程调用计数器 i,运行后的值为:" + counter.i);
    }

    public void count() {i++;}
}

运行以上的代码发现:当线程数量减少,每个线程调用计数器的次数变大时,每次运行的后果是谬误且不固定的。

为了实现切实一个多线程环境下、线程平安的计数器,须要应用 AtomicInteger 的原子自增运算。

import java.util.concurrent.atomic.AtomicInteger;
public class SafeCount {private AtomicInteger atomic = new AtomicInteger(0);
    private static final int THREAD_COUNT = 200;
    public static void main(String[] args) {SafeCount counter = new SafeCount();
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {for (int j=0;j<10000;j++){counter.count();
                    }
                }
            });
            threads[i].start();}
        while (Thread.activeCount()>1){Thread.yield();
        }
        System.out.println("多线程调用线程平安的计数器 atomic:"+counter.atomic);
    }
    public void count() {
        // 调用 compareAnSet 办法,应用循环的 CAS 操作实现计数器的原子自增
        for (; ;) {int expect = atomic.get();
            int curVal = expect + 1;
            if (atomic.compareAndSet(expect, curVal)) {break;}
        }
    }
}

与非线程平安的计数器相比,线程平安的计数器有以下特点:

  • 将 int 类型的计数器变量 i,更换成具备 CAS 操作的 AtomicInteger 类型的计数器变量 atomic。
  • 进行自增运算时,通过循环的 CAS 操作实现 atomic 的原子自增。
  • 先通过 atomic.get()获取 expect 的值,将 expect 加一失去新值,而后通过 atomic.compareAndSet(expect, curVal) 这一办法实现 CAS 操作。
  • 其中 compareAndSet()返回的 true 或者 false,示意此次 CAS 操作是否胜利。如果返回 false,则不停地反复执行 CAS 操作,直到操作胜利。

下面的 count 办法实现的 AtomicInteger 原子自增,能够只须要调用 incrementAndGet()一个办法就能实现。

public void count() {
    // 调用 incrementAndGet 办法,实现 AtomicInteger 的原子自增
    atomic.incrementAndGet();}

因为 incrementAndGet()办法,封装了通过循环的 CAS 操作实现 AtomicInteger 原子自增的代码。

public final int incrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}
(3)CAS 操作存在的问题

1. ABA 问题

  • 在执行 CAS 操作更新共享变量的值时,如果一个值原来是 A,被其余线程改成了 B,而后又改回成了 A。对于该 CAS 操作来说,它齐全感触不到共享变量值的变动。这种操作破绽称为 CAS 操作的 ABA 问题。
  • 解决该问题的思路是,为变量增加版本号,每次更新时版本号递增。这种场景下就成了 1A –> 2B –> 3A。CAS 操作就能检测到共享变量的 ABA 问题了。
  • JUC 包中,也提供了相应的带标记的原子援用类 AtomicStampedReference 来解决 ABA 问题。
  • AtomicStampedReference 的 compareAndSet()办法会首先比拟期待的援用是否等于以后援用,而后查看期待的标记是否等于以后标记。如果全副相等,则以原子操作的形式将新的援用和新的标记更新到以后值中。
  • 然而 AtomicStampedReference 目前比拟鸡肋,如果想解决 AB 问题,能够应用锁。

2. 循环工夫过长,开销大

循环的 CAS 操作如果长时间不胜利,会给 CPU 带来十分大的执行开销。

3. 只能保障一个共享变量的原子操作

  • 只对一个共享变量执行操作时,能够通过循环的 CAS 操作实现。如果是多个共享变量,循环的 CAS 操作无奈保障操作的原子性。
  • 取巧的操作:将多个共享变量合为一个变量进行 CAS 操作。JDK1.5 开始,提供了 AtomicReference 类保障援用对象之间的原子性,能够将多个变量放在一个对象中进行 CAS 操作。

③ 无同步计划

同步只是保障共享数据争用时正确性的一种伎俩,如果不存在共享数据,天然毋庸任何同步措施。

(1)栈关闭

多个线程拜访同一个办法的局部变量时,不会呈现线程平安问题。

因为办法中的局部变量不会逃出该办法而被其余线程拜访,因而能够看做 JVM 栈中数据,属于线程公有。

(2)可重入代码(Reentrant Code)

可重入代码又叫纯代码(Pure Code),可在代码执行的任何时候中断他它,转去执行另外一段代码(包含递归调用它自身),控制权返回后,原来的程序不会呈现任何谬误。

所有可重入的代码都是线程平安,并非所有线程平安的代码都是可重入的。

可重入代码的独特特色:

  • 不依赖存储在堆上的数据和专用的系统资源
  • 用到的状态量都由参数中传入
  • 不调用非可重用的办法

如何判断代码是否具备可重入性?如果一个办法,它的返回后果是可预测的。只有输出了雷同的数据,就都能返回雷同的后果,那它就满足可重入性,当然也就是线程平安的。

(3)线程本地存储(TLS)

线程本地存储(Thread Local Storage):

  • 如果一段代码中所须要的数据必须与其余代码共享,那就看看这些共享数据的代码是否能保障在同一个线程中执行。
  • 如果能保障,咱们就能够把共享数据的可见范畴限度在同一个线程内。
  • 这样,毋庸同步也能保障线程之间不呈现数据争用的问题。

TLS 的重要利用实例:经典的 Web 交互模型中,一个申请对应一个服务器线程,使得 Web 服务器利用能够应用。

Java 中没有关键字能够将一个变量定义为线程所独享,然而 Java 中创立了 java.lang.ThreadLocal 类提供线程本地存储性能。

  • 每一个线程外部都蕴含一个 ThreadLocalMap 对象,该对象将 ThreadLocal 对象的 hashCode 值作为 key,即 ThreadLocal.threadLocalHashCode,将本地线程变量作为 value,形成键值对。
  • ThreadLocal 对象是以后线程 ThreadLocalMap 对象的拜访入口,通过 threadLocal.set() 为本地线程增加独享变量;通过 threadLocal.get() 获取本地线程独享变量的值。
  • ThreadLocal、ThreadLocalMap、Thread 的关系:Thread 对象中蕴含 ThreadLocalMap 对象,ThreadLocalMap 对象中蕴含多个键值对,每个键值对的 key 是 ThreadLocal 对象的 hashCode,value 是本地线程变量。

ThreadLocal 的编程实例:

  • 想为某个线程增加本地线程变量,必须通过 ThreadLocal 对象在该线程中进行增加,结构出的键值对主动存入该线程的 map 中;
  • 想要获取某个线程的本地线程变量,必须在该线程中获取,会主动查问该线程的 map,取得 ThreadLocal 对象对应的 value。
  • 通过 ThreadLocal 对象反复为某个线程增加键值对,会笼罩之前的 value。
public class TLS {public static void main(String[] args) {ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
        ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 设置以后线程的本地线程变量
                threadLocal1.set("thread1");
                threadLocal2.set(1);
                System.out.println(threadLocal1.get() + ":" + threadLocal2.get());
                // 应用结束后要删除,防止内存泄露
                threadLocal1.remove();
                threadLocal2.remove();}
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {threadLocal1.set("thread2");
                threadLocal2.set(2);
                System.out.println(threadLocal1.get() + ":" + threadLocal2.get());
                threadLocal1.remove();
                threadLocal2.remove();}
        });
        thread1.start();
        thread2.start();
        // 没有通过 ThreadLocal 为主线程增加过本地线程变量,获取到的内容都是 null
        System.out.println(threadLocal1.get()+":"+threadLocal2.get());
    }
}

对 ThreadLocal 的正确理解:

  • ThreadLocal 实用于线程须要有本人的实例变量,该实例变量能够在多个办法中被应用,然而不能被其余线程共享的场景。
  • 因为不存在数据共享,何谈同步?因而 ThreadLocal 从实践上讲,不是用来解决多线程并发问题的。

ThreadLocal 的实现:

最原始的想法:ThreadLocal 保护线程与实例的映射。既然通过 ThreadLocal 对象为线程增加本地线程变量,那就将 ThreadLocalMap 放在 ThreadLocal 中。

原始想法存在的缺点:多线程并发拜访 ThreadLocal 中的 Map,须要增加锁。这是,JDK 未采纳该计划的一个起因。

优化后的办法:Thread 保护 ThreadLocal 与实例的映射。Map 是每个线程所公有,只能在以后线程通过 ThreadLocal 对象拜访本身的 Map。不存在多线程并发拜访同一个 Map 的状况,也就不须要锁。

优化后存在内存泄露的状况:JDK1.8 中,ThreadLocalMap 每个 Entry 对 ThreadLocal 对象是弱援用,对每个实例是强援用。当 ThreadLocal 对象被回收后,该 Entry 的键变成 null,但 Entry 无奈被移除。使得实例被 Entry 援用无奈回收,造成内存泄露。

近期热文举荐:

1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)

2. 劲爆!Java 协程要来了。。。

3.Spring Boot 2.x 教程,太全了!

4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!

5.《Java 开发手册(嵩山版)》最新公布,速速下载!

感觉不错,别忘了顺手点赞 + 转发哦!

正文完
 0