为什么要了解多线程

序言

为什么要了解多线程

打个比方1万个人一起同时向同一家银行的同一账户存1块钱,但是最后在该账户上的总钱数少于1万钱,那这家银行是不是要破产?

也许你现在的工作中很少会使用到多线程,基本上%90以上的代码都不会用到,但是哪一天如果要遇到需要考虑多线程的场景,那么你不懂这个就是致命的。

虽然不用多线程,单线程也能实现功能,但是对性能有要求时,多线程是另一个选择。就好比在某些场景下接口对时效性要求不高,那么把该接口设计为异步接口就是你的另一个可选项了。

什么是线程

  • 进程:它是操作系统进行资源分配和调度的独立单位,是应用程序运行的载体。
  • 线程:它是程序执行流的最小单元,一个进程可以有多个线程。
  • 协程:它是一种程序组件。线程的上下文切换受系统控制,而协程的上下文切换由自己控制。

先来看一下进程,mac打开活动监视器(windows直接打开任务管理器即可)可以看到,活动监视器本身也是一个进程

 再来看一下线程,IDEA使用Jconsole工具就可以看到Java应用程序中的所有线程,

 并发三大要素

并发需要考虑的三大要素:

  • 可见性:多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。
  • 原子性:指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行。
  • 有序性:程序的执行顺序按照代码的先后顺序来执行。

下面通过几个例子来展开并发三要素,

 可见性

多个线程同时对同一个变量进行操作时,为确保可见性我们一般会在变量上加上volatile关键字来保证线程可见性。那么如果不加会出现什么情况呢?下面就以一个例子说明,

下面的场景childA一直跳绳直到childB喊停,childB喊停时flag标志位会置为true,childA看到标志位为true就会停下。很简单的一个场景,然后childB执行了stop方法,但是标志flag并没有改变,这就是线程不可见。

package com.hust.zhang.JUC;

public class VisibleDemo {
    // 标记是否需要停止
    private boolean flag = false;

    public void stop() {
        this.flag = true;
        System.out.println(Thread.currentThread().getName() + "小孩叫停了!");
    }

    public void ropeSkipping() {
        String name = Thread.currentThread().getName();
        int i = 0;
        while (!flag) {
            i++;
        }
        System.out.println(name + "小孩跳了: " + i + "次");
    }

    public static void main(String[] args) {
        VisibleDemo instance = new VisibleDemo();
        Thread childB = new Thread(instance::stop, "childB");

        // childA一直跳直到childB喊停
        Thread childA = new Thread(instance::ropeSkipping, "childA");
        childA.start();
        try {
            Thread.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // childB喊停了,A不知道
        childB.start();
    }
}

在上面的代码里对flag变量加上volatile关键字(如下),这样就可以保证flag的线程可见性,就能够达到预期的效果。除了volatile关键字,synchronized关键字和final关键字也可以保证线程的可见性。这个后面再讲,但是需要注意final关键字修饰的变量是常量,是不可以修改的,而synchronized关键字是加锁操作,只能修饰方法(锁定的是调用该方法的对象)、代码块、对象、类。

private volatile boolean flag = false;

Java内存模型中,

1、childA和childB都从主内存读取flag变量的值到工作内存(寄存器)。

2、childB在工作内存中改变flag的值为true

如果不加上volatile关键字确保变量flag的可见性,那么childA每次读取主内存都会读到flag=false。而加上volatile关键字时通过JMM控制能确保childB或其他线程对变量flag的修改及时回写到主内存。

备注:

1、volatile关键字不能保证原子性,但可以保证可见性,并且禁止指令重排序

编译器和虚拟机为了做性能优化,可能会存在指令重排,加上volatile关键字可以告诉计算机什么不能进行指令重排。就比如使用transient关键字修饰不想被序列化的对象或属性。

2、JMM模型和MESI缓存一致性协议的CPU Cache模型类似,但是是不同的概念。MESI保证的是多核cpu的独占cache之间的数据一致性。

3、Happens-before原则是指在程序运行过程中,所有的变更会先在寄存器或本地cache中完成,然后才会被拷贝到主存以跨越内存栅栏(本地或工作内存到主存之间的拷贝动作),此种跨越序列或顺序称为happens-before。

原子性

下面以一个简单的demo,就按文章开头说的,启了1w个线程,每个线程对变量count进行自增操作,但最后的结果总是小于1w。为什么?因为i++不是原子操作。

package com.hust.zhang.JUC;

public class ThreadDemo {
    private static final int THREAD_NUMBER = 10000;
    private static int count = 0;
    public static void main(String[] args) {
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> count++).start();
        }
        System.out.println(count);
    }
}

将下面的Java代码通过javac编译成.class文件,

    public static void main(String[] args) {
        int i = 0;
        i++;
    }

再通过javap -c 反编译成汇编语言,

有人可能好奇为什么非要先把java编译成class文件,再反编译成汇编语言,因为汇编语言是最接近机器的语言,通过它我们可以了解到机器是如何操作的,

上面几句汇编指令的含义是:

  1. 先将常量0压入操作数
  2. 弹出操作数栈栈顶元素,保存到局部变量表第1位置
  3. 把局部变量1,也就是i,增加1,这个指令不会导致栈的变化,i此时变成1

iinc 1,1指令分为下面三步,

  1. 读内存到寄存器;
  2. 在寄存器中自增;
  3. 写回内存。

那么如何保证这段操作是原子操作,对这段操作进行加锁就可以实现,加锁简单的理解就是你把这段代码锁住了只有等它执行完了再释放锁。但真实的加锁是什么样的呢?后面再一一介绍。先对上面的代码进行加锁操作。发现加锁后仍然得不到正确的结果,这是由于CPU时间片轮给主线程执行时子线程还没有执行完,所以我们可以在1w个子线程下面休眠一秒来确保子线程执行完。

public class ThreadDemo {
    private static final int THREAD_NUMBER = 10000;
    private static final Object lock = new Object();
    private static volatile int count = 0;
    public static void main(String[] args) {
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                synchronized (lock) {
                    count++;
                }
            }).start();
        }
        // 休眠1秒确保所有子线程执行完毕
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count);
    }
}

了解的人看到上面的加锁方式就知道这是一种很粗粒度的加锁操作,使用了一把对象锁。Synchronized关键字本质上是修改的对象锁标志位信息。下面左图是一个Java对象的内存结构(对象头占8个字节,类型指针压缩可占4字节,一般占8字节,实例数据是多少就是多少,对齐填充补齐到能被8字节整除),锁的信息在8字节的对象头(Mark Word)中,而右图是Mark Word在Hotspot中的比特位信息。

 粗粒度的锁占用了一些不必要的开销,所以通常的做法是使用无锁(Spin Lock,自旋锁),如下图所示,原子整型底层的实现就是利用自旋锁。

不过JDK1.6之后JVM为了提高锁的获取和释放速度对synchronized进行了优化,随着锁的竞争激烈程度逐渐升级(无锁—>偏向锁—>轻量级锁—>重量级锁),

package com.hust.zhang.JUC;

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo {
    private static final int THREAD_NUMBER = 10000;
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) {
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> count.incrementAndGet()).start();
        }
        // 休眠1秒确保所有子线程执行完毕
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(count.get());
    }
}

JVM对锁的优化有,

  1. 锁升级:为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
  2. 锁消除:虚拟机即时编译器(JIT)在运行时,对检测到不可能存在共享数据竞争的锁进行消除。例如StringBuffer线程安全的append方法在单线程中执行多次。
  3. 锁粗化:JVM监测到对同一个对象反复加锁和解锁,则会粗化这种加锁操作,因为频繁地进行互斥同步操作会导致不必要的性能损耗。

有序性

前面的例子中在下面的主线程可能先于子线程执行完了输出操作,这就是因为没有考虑有序性的原因,那下面来看看线程的生命周期,

每个线程的生命周期如上所示,都会经历新建状态到就绪状态,主线程(main线程)和子线程都处于就绪态后,后面就由CPU资源调度来决定谁先run(),并不是说子线程代码在前面它就先执行。CPU时间片轮到谁,谁先执行。

那么有人问了对各个线程设置优先级(setPriority方法)能够保证线程的顺序吗?

答案是不行,因为Java的线程调度是基于操作系统以及JVM实现,在不同的操作系统中或不同厂商的JVM(如Oracle、IBM等),即使使用同一套代码,其多线程的调度机制也是不一样的。

线程池

池化技术 (Pool) 是一种很常见的编程技巧,在请求量大时能明显优化应用性能,降低系统频繁建连的资源开销。如内存池、连接池、线程池等。前面也写过线程池的相关内容,你可以把它当作一个倒立的漏洞,有一篇可视化的文章讲的比较详细,参看文末链接2。Java线程池的核心线程数和最大线程数总是容易混淆怎么办 - 知乎

另外需要指正一下该文章中的瓶口大小应该是maximumPoolSize-corePoolSize。

下面来创建一个通用线程池在后续使用。

package com.hust.zhang.JUC;

import com.google.common.util.concurrent.ThreadFactoryBuilder;

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class CommonThreadPool {
    /**
     * 使用静态内部类创建单实例对象
     */
    private static class Holder {
        private static final CommonThreadPool INSTANCE = new CommonThreadPool();
    }

    public static CommonThreadPool getInstance() {
        return Holder.INSTANCE;
    }

    private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR;
    //核心线程数
    private static final int corePoolSize = 100;
    //最大线程数
    private static final int maxPoolSize = 200;
    //工作队列大小
    private static final int workQueueCapacity = 3000;
    //空闲线程存活时间 30s
    private static final long keepAliveTime = 30 * 1000L;

    /**
     * 静态代码块初始化线程池参数
     */
    static {
        THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(corePoolSize,
                maxPoolSize,
                keepAliveTime,
                TimeUnit.MICROSECONDS,
                new LinkedBlockingQueue<>(workQueueCapacity),
                //使用链式方法创建ThreadFactory
                new ThreadFactoryBuilder().setNameFormat("my_test_thread").build(),
                new ThreadPoolExecutor.AbortPolicy());
    }

    void submit(Runnable task) {
        THREAD_POOL_EXECUTOR.submit(task);
    }

    void shutdown(){
        THREAD_POOL_EXECUTOR.shutdown();
    }
}

另外,很多公司的编程规范手册或开发手册中提到禁止使用Executors去创建线程池。这是为什么?其实使用Executors创建线程池不一定会出现问题,你去看一些老项目工程里还会看到这样去创建线程池的例子。而且这些工程项目可能运行到现在还没有出现问题,那这是因为该项目的应用场景不一样。

强制线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

Executors返回的线程池对象的弊端如下:

  • FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
  • CachedThreadPool和ScheduledThreadPool:允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

看上面可视化的线程池可以知道当漏斗瓶口和瓶颈无限大时会把你的整个系统的资源占尽,从而导致Out Of Memory!

线程的并发控制

线程的并发控制是一个比较大的概念,并发是指同时操作多个线程,如何保证原子性、一致性、有序性,我们可以使用实现了AbstractQueuedSynchronizer的并发工具类。

  • CountDownLatch:具有计数器功能的控制器。
  • CyclicBarrier:回环栅栏,让一组线程等待至某个状态之后再全部同时执行。叫做回环是因为当一组线程被释放以后,CyclicBarrier可以被重用。
  • Semaphore:信号量,Semaphore可以控同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。

CountDownLatch

还是上面的例子,我们使用CountDownLatch实现,每个线程countdown一次,AQS的计数器值减1,直到为0,主线程的await方法才不会阻塞。

package com.hust.zhang.JUC;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public class CountDownLatchDemo {
    /**
     * 线程总数
     */
    private static final int THREAD_NUMBER = 10000;
    /**
     * 原子整型计数器
     */
    private static final AtomicInteger COUNTER = new AtomicInteger(0);

    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(THREAD_NUMBER);
        for (int i = 0; i < THREAD_NUMBER; i++) {
            CommonThreadPool.getInstance().submit(() -> {
                try {
                    COUNTER.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }
        try {
            latch.await();
        } catch (InterruptedException e) {
            System.out.println("CountDownLatch await method interruptedException");
        }
        System.out.println(COUNTER.get());
        CommonThreadPool.getInstance().shutdown();
    }
}

CyclibcBarrier

10个线程一组,有一个线程没有到,这一组其他线程都在等着,释放之后,CyclicBarrier栅栏又可以继续使用。

package com.hust.zhang.JUC;

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;

public class CyclicBarrierDemo {
    /**
     * 线程总数
     */
    private static final int THREAD_NUMBER = 10000;
    /**
     * 每组线程数
     */
    private static final int THREAD_NUMBER_EACH_GROUP = 10;
    /**
     * 分组数
     */
    private static final int GROUP_NUMBER = THREAD_NUMBER / THREAD_NUMBER_EACH_GROUP;
    /**
     * 原子整型计数器
     */
    private static final AtomicInteger COUNTER = new AtomicInteger(0);

    /**
     * 栅栏
     */
    private static final CyclicBarrier BARRIER = new CyclicBarrier(THREAD_NUMBER_EACH_GROUP, () ->
            System.out.println(THREAD_NUMBER_EACH_GROUP + "个线程一组一起完成任务"));

    public static void main(String[] args) {
        // 如果出现死锁,通过jps查看进程,然后jstack PID查看堆栈信息;或者使用jconsole查看线程状态
        for (int i = 1; i <= GROUP_NUMBER; i++) {
            for (int j = 1; j <= THREAD_NUMBER_EACH_GROUP; j++) {
                int groupId = i;
                int threadId = j;
                CommonThreadPool.getInstance().submit(() -> {
                    System.out.println("第" + groupId + "组的第" + threadId + "个线程准备就绪");
                    try {
                        // 只有BARRIER的10个线程都准备就绪了才会一起执行
                        BARRIER.await();
                    } catch (InterruptedException | BrokenBarrierException e) {
                        e.printStackTrace();
                    }
                    COUNTER.incrementAndGet();
                });
            }
        }
        // 休眠1秒,避免时间片轮到主线程,直接输出了
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(COUNTER.get());
        CommonThreadPool.getInstance().shutdown();
    }
}

Semaphore

信号量不太适合计数,下面先以一个死锁的案例,然后看信号量如何解决死锁问题,

线程A先去锁住对象1,休眠3秒再去锁对象2;线程B先去锁住对象2,休眠3秒再去锁对象1,但是此时A锁住了对象1,B锁住了对象2,A想去锁对象2,B想去锁对象1都不可能了。

package com.hust.zhang.JUC;

import java.util.Date;

/**
 * 死锁的四个条件:
 * 1、互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
 * 2、不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
 * 3、请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
 * 4、循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
 */
public class DeadlyEmbrace {
    private static final String OBJ_1 = "obj1";
    private static final String OBJ_2 = "obj2";
    private static final long SLEEP_TIME = 3 * 1000;
    public static void main(String[] args) {
        CommonThreadPool.getInstance().submit(() -> {
            try {
                System.out.println(new Date() + " LockA 开始执行");
                while (true) {
                    synchronized (OBJ_1) {
                        System.out.println(new Date() + " LockA 锁住 obj1");
                        Thread.sleep(SLEEP_TIME);
                        synchronized (OBJ_2) {
                            System.out.println(new Date() + " LockA 锁住 obj2");
                            Thread.sleep(SLEEP_TIME);
                            System.out.println(" 线程A 执行三秒任务");
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        CommonThreadPool.getInstance().submit(() -> {
            try {
                System.out.println(new Date() + " LockB 开始执行");
                while (true) {
                    synchronized (OBJ_2) {
                        System.out.println(new Date() + " LockB 锁住 obj2");
                        Thread.sleep(SLEEP_TIME); // 此处等待是给A能锁住机会
                        synchronized (OBJ_1) {
                            System.out.println(new Date() + " LockB 锁住 obj1");
                            Thread.sleep(SLEEP_TIME);
                            System.out.println(" 线程B 执行三秒任务");
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        CommonThreadPool.getInstance().shutdown();
    }
}

使用jps查到对应的进程PID,然后jstack PID查看堆栈信息,如下两个线程都处于阻塞状态, 

使用JConsole工具也可以看到,线程均处于阻塞状态,等待锁定对方已经锁定的对象。

  那么使用信号量如何解决这种死锁问题呢?看下面,信号量可以在指定的时间内尝试地获得1个许可,如果获取不到则获取失败(返回false)。

package com.hust.zhang.JUC;

import java.util.Date;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SemaphoreDemo {
    private static final long SLEEP_TIME_THREE_SECOND = 3 * 1000L;

    private static final long SLEEP_TIME_TEN_SECOND = 10 * 1000L;

    private static final long SLEEP_TIME_ONE_SECOND = 1000L;

    private static final Semaphore OBJ_1 = new Semaphore(1);

    private static final Semaphore OBJ_2 = new Semaphore(1);

    public static void main(String[] args) {
        CommonThreadPool.getInstance().submit(() -> {
            try {
                System.out.println(new Date() + " LockA 开始执行");
                while (true) {
                    if (OBJ_1.tryAcquire(1, TimeUnit.SECONDS)) {
                        System.out.println(new Date() + " LockA 尝试获取锁1秒");
                        if (OBJ_2.tryAcquire(1, TimeUnit.SECONDS)) {
                            System.out.println(new Date() + " LockB 尝试获取锁1秒");
                            // 等待1分钟
                            Thread.sleep(SLEEP_TIME_THREE_SECOND);
                            System.out.println(" 线程A 执行三秒任务");
                        } else {
                            System.out.println(new Date() + "LockA 锁 对象B 失败");
                        }
                    } else {
                        System.out.println(new Date() + "LockA 锁 对象A 失败");
                    }
                    SemaphoreDemo.OBJ_1.release();
                    SemaphoreDemo.OBJ_2.release();
                    // 等待1秒
                    Thread.sleep(SLEEP_TIME_ONE_SECOND);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        CommonThreadPool.getInstance().submit(() -> {
            try {
                System.out.println(new Date() + " LockB 开始执行");
                while (true) {
                    if (OBJ_2.tryAcquire(1, TimeUnit.SECONDS)) {
                        System.out.println(new Date() + " LockB 尝试获取锁1秒");
                        if (OBJ_1.tryAcquire(1, TimeUnit.SECONDS)) {
                            System.out.println(new Date() + " LockA 尝试获取锁1秒");
                            Thread.sleep(SLEEP_TIME_THREE_SECOND);
                            System.out.println(" 线程B 执行三秒任务");
                        } else {
                            System.out.println(new Date() + "LockB 锁 对象A 失败");
                        }
                    } else {
                        System.out.println(new Date() + "LockB 锁 对象B 失败");
                    }
                    OBJ_1.release();
                    OBJ_2.release();
                    Thread.sleep(SLEEP_TIME_TEN_SECOND);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        CommonThreadPool.getInstance().shutdown();
    }
}

参考链接:

1、《Java高并发编程详解:多线程与架构设计》笔记(三)_四问四不知的博客-CSDN博客

2、Java线程池的核心线程数和最大线程数总是容易混淆怎么办 - 知乎

3、【麻省理工学院】分布式系统_Java公开课_哔哩哔哩_bilibili

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值