• Java基础系列(七)——多线程


    目录

    多线程

    Volatile关键字

    Synchronized关键字

    修饰实例方法

    修饰静态方法

    修饰代码块

    synchronized 关键字的底层原理

    synchronized与volatile的区别

    Q&A

    使用多线程可能带来什么问题?

    sleep() 方法和 wait() 方法对比

    为什么 wait() 方法不定义在 Thread 中?

    可以直接调用 Thread 类的 run 方法吗?


    多线程

    首先,需要了解什么是线程,为什么会有多线程的出现。

    先从总体上来说:

    • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
    • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

    再深入到计算机底层来探讨:

    • 单核时代: 在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
    • 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。

    没有什么东西是完美的,那么,并发线程会导致什么问题呢?

    并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

    顺便简单说下,并发与并行的区别。

    • 并发:两个及两个以上的作业在同一 时间段 内执行。
    • 并行:两个及两个以上的作业在同一 时刻 执行。

    最关键的点是:是否是 同时 执行。

    接下来就是多线程常见的知识点:volatile关键字、synchronized关键字、线程池…

    Volatile关键字

    volatile是最轻量级的同步机制

    特性:

    • 保证变量可见性
    • 禁止指令重排
    • 不保证原子性

    如何保证变量可见性

    在Java中,volatile关键字在每次读取变量时都是到主存中进行读取的。若变量使用volatile进行修饰,那么表明这个变量共享且不稳定,即保证数据的可见性,却不保证数据的原子性。而synchronized关键字两者都可以保证。

    如何保证禁止指令重排

    如果我们将变量声明为volatile时,在对这个变量进行读写操作时,会通过插入特定的内存屏障的方式进行禁止指令重排序。

    简单说下什么是内存屏障:

    内存屏障,又称内存栅栏,是一个CPU指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM为了保证在不同的编译器和CPU上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

    回归正题,我们这里用double check方式实现单例模式。很多人会忽略volatile关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是100%,说不定在未来的某个时刻,隐藏的bug就出来了。

    1. public class Singleton {
    2. private volatile static Singleton uniqueInstance;
    3. private Singleton() {
    4. }
    5. public static Singleton getUniqueInstance() {
    6. //先判断对象是否已经实例过,没有实例化过才进入加锁代码
    7. if (uniqueInstance == null) {
    8. //类对象加锁
    9. synchronized (Singleton.class) {
    10. if (uniqueInstance == null) {
    11. uniqueInstance = new Singleton();
    12. }
    13. }
    14. }
    15. return uniqueInstance;
    16. }
    17. }

    我们可以看到,uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

    1、为uniqueInstance分配内存空间

    2、初始化uniqueInstance

    3、将uniqueInstance指向分配的内存地址

    由于JVM可能存在指令重排的情况,执行的顺序可能会变成1->3->2,那么在多线程的情况下,可能会导致线程获取到没有初始化的实例对象。

    举个例子:

    有两个线程分别为T1和T2,T1执行new Singleton()这行的时候,由于没有加上volatile关键字,JVM产生了指令重排,只执行了1->3,还没有执行到初始化的时候。这个时候,T2调用getUniqueInstance()方法,由于uniqueInstance已经指向了分配的内存地址,所以uniqueInstance!= null,这个时候就会返回uniqueInstance,但是,这个时候我们可以看到uniqueInstance其实并没有进行初始化。

    验证volatile不保证原子性

    我们可以通过一段代码进行测试,volatile关键字不保证数据的原子性。

    1. public class VolatoleAtomicityDemo {
    2. public volatile static int inc = 0;
    3. public void increase() {
    4. inc++;
    5. }
    6. public static void main(String[] args) throws InterruptedException {
    7. ExecutorService threadPool = Executors.newFixedThreadPool(5);
    8. VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
    9. for (int i = 0; i < 5; i++) {
    10. threadPool.execute(() -> {
    11. for (int j = 0; j < 500; j++) {
    12. volatoleAtomicityDemo.increase();
    13. }
    14. });
    15. }
    16. // 等待1.5秒,保证上面程序执行完成
    17. Thread.sleep(1500);
    18. System.out.println(inc);
    19. threadPool.shutdown();
    20. }
    21. }

    正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500

    为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!

    也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

    很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

    1. 读取 inc 的值。
    2. 对 inc 加 1。
    3. 将 inc 的值写回内存。

    volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

    1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。
    2. 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。

    这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。

    其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized 、Lock或者AtomicInteger都可以。

    1. // synchronized
    2. public synchronized void increase() {
    3. inc++;
    4. }
    5. // AtomicInteger
    6. public AtomicInteger inc = new AtomicInteger();
    7. public void increase() {
    8. inc.getAndIncrement();
    9. }
    10. // ReentrantLock
    11. Lock lock = new ReentrantLock();
    12. public void increase() {
    13. lock.lock();
    14. try {
    15. inc++;
    16. } finally {
    17. lock.unlock();
    18. }
    19. }

    Synchronized关键字

    在Java中,synchronized主要解决的是多线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任一时刻只有一个线程执行。

    主要有三种使用方式:

    1. 修饰实例方法
    2. 修饰静态方法
    3. 修饰代码块

    修饰实例方法

    给当前的对象实例加锁,进入同步代码前要获得当前对象实例的锁。

    1. synchronized void method() {
    2. //业务代码
    3. }

    修饰静态方法

    给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁。

    这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

    1. synchronized static void method() {
    2. //业务代码
    3. }

    静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

    修饰代码块

    对括号里指定的对象/类加锁:

    • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
    • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
    1. synchronized(this) {
    2. //业务代码
    3. }

    总结:

    • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
    • synchronized 关键字加到实例方法上是给对象实例上锁;
    • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

    synchronized 关键字的底层原理

    synchronized关键字底层由JVM实现。

    修饰同步语句块,获取锁对象的情况:

    通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。

    1. public class SynchronizedDemo {
    2. public void method() {
    3. synchronized (this) {
    4. System.out.println("synchronized 代码块");
    5. }
    6. }
    7. }

    从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

    当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

    在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。对象锁的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

     如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

    修饰方法的情况:

    1. public class SynchronizedDemo2 {
    2. public synchronized void method() {
    3. System.out.println("synchronized 方法");
    4. }
    5. }

     synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

    如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

    总结

    synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

    synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

    不过两者的本质都是对对象监视器 monitor 的获取。

    synchronized与volatile的区别

    synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

    • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
    • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
    • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

    Q&A

    使用多线程可能带来什么问题?

    并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

    sleep() 方法和 wait() 方法对比

    共同点 :两者都可以暂停线程的执行。

    区别 :

    • sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
    • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
    • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
    • sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?

    为什么 wait() 方法不定义在 Thread 中?

    wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

    类似的问题:为什么 sleep() 方法定义在 Thread 中?

    因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

    可以直接调用 Thread 类的 run 方法吗?

    new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

    总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

  • 相关阅读:
    mysql创建触发器
    数据集快速生成方法集合
    面经 | Go语言知识点
    【四】关系模型 -- 关系代数
    MySQL 极速安装使用与卸载
    零基础可以考软考高级吗?
    ruoyi-vue前后端分离版本验证码实现思路
    java MessageDigest 实现加密算法
    【重识云原生】第六章容器6.1.7.2节——cgroups原理剖析
    国产数据库兼容性认证再下两城,极狐GitLab 国产适配更进一步
  • 原文地址:https://blog.csdn.net/Stray_Lambs/article/details/125996938