Java 线程基础

作者: 小鱼人爱编程 | 来源:发表于2021-10-11 13:18 被阅读0次

    前言

    线程并发系列文章:

    Java 线程基础
    Java 线程状态
    Java “优雅”地中断线程-实践篇
    Java “优雅”地中断线程-原理篇
    真正理解Java Volatile的妙用
    Java ThreadLocal你之前了解的可能有误
    Java Unsafe/CAS/LockSupport 应用与原理
    Java 并发"锁"的本质(一步步实现锁)
    Java Synchronized实现互斥之应用与源码初探
    Java 对象头分析与使用(Synchronized相关)
    Java Synchronized 偏向锁/轻量级锁/重量级锁的演变过程
    Java Synchronized 重量级锁原理深入剖析上(互斥篇)
    Java Synchronized 重量级锁原理深入剖析下(同步篇)
    Java并发之 AQS 深入解析(上)
    Java并发之 AQS 深入解析(下)
    Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 详解
    Java 并发之 ReentrantLock 深入分析(与Synchronized区别)
    Java 并发之 ReentrantReadWriteLock 深入分析
    Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
    Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(应用篇)
    最详细的图文解析Java各种锁(终极篇)
    线程池必懂系列

    熟练掌握线程原理与使用是程序员进阶的必经之路,网上很多关于Java线程的知识,比如多线程之间变量的可见性、操作的原子性,进而扩展出的Volatile、锁(CAS/Synchronized/Lock)、信号量等知识。有些文章只说笼统的概念、有些文章深入底层源码令人迷失其中、有些文章只说了其中某个点没有提及内在的联系。
    基于以上原因,本系列文章尝试由浅入深、系统性地分析、总结Java线程相关知识,算是加深印象、夯实基础,也算是抛砖引玉。若是相关文章对各位看官有所帮助,幸甚至哉。
    通过本篇文章,你将了解到:

    1、进程与线程区别
    2、开启/停止线程
    3、线程的交互

    1、进程与线程区别

    程序与进程

    平时所说的编写一个程序/软件,比如编写好一个APK,这个APK可以直接传送给另一个设备安装,这时候我们说发送给你一个程序/软件,是个静态的单个文件/多个文件的集合。
    当安装好APK之后,运行该APK,该程序就被CPU执行了,这时候我们称这个进程在运行了。因此进程是程序的动态表现,也是CPU执行时间段的描述。
    [图片上传失败...(image-8d0157-1633929450254)]
    当然,程序与进程也不是一一对应关系,也就是说一个程序里可以fork()多个进程来执行任务。

    进程与线程

    CPU调度执行程序之前,需要准备好一些数据,如程序所在的内存区域,程序需要访问的外设资源等,程序运行过程中产生的一些中间变量需要临时存储在寄存器等。这些与进程本身关联的东西称之为进程上下文。

    由此引发的问题:CPU在切换进程的过程中势必涉及到上下文的切换,切换的过程会占用CPU时间。

    image.png

    通俗点理解就是:进程1先被CPU调度执行,执行了一段时间后调度进程2执行,此时上下文就会切换成与进程2相关的。
    再考虑另一种情形:一个程序里实现了A、B两个有关联的功能,两者在不同的进程实现,A进程需要与B进程交互,该过程就是个IPC(进程间通信)。我们知道,IPC需要共享内存或者陷入内核调用,这些操作代价比较大。
    Android 进程间通信系列文章请移步:Android IPC 看了都懂系列

    随着计算机硬件越来越强大,CPU频率越来越高,甚至还发展出多个CPU。为了充分利用CPU,线程应运而生。
    进程被分为更小的粒度,原本一个进程要执行A、B、C三个任务,现在将这三个任务分别放在三个线程里执行。


    image.png

    可以看出,CPU调度的基本单位就是线程。

    进程与线程关系

    1、进程与线程均是CPU执行时间段的描述。
    2、进程是资源分配的基本单位,线程是CPU调度的基本单位。
    3、一个进程里至少有一个线程。
    4、同一进程里的各个线程可以共享变量,它们之间的通信称之为线程间通信。
    5、线程可以看作粒度更小的进程。

    线程的优势

    1、开启新线程远比开启新进程节约资源,并且更快速。
    2、线程间通信比IPC简单、快捷易于理解。
    3、符合POSIX规范的线程可以跨平台移植。

    2、开启/停止线程

    既然线程如此重要,那么来看看Java中如何开启与停止线程。

    开启线程

    查看Thread.java源码可知,Thread实现了Runnable接口,因此需要重写Runnable方法:run()。

    #Thread.java
        @Override
        public void run() {
            if (target != null) {
                target.run();
            }
        }
    

    而线程开启后执行任务的方法即是run()。
    该方法里先判断target是否不为空,若是则执行target.run()。

    #Thread.java
        /* What will be run. */
        private Runnable target;
    

    target为Runnable类型,该引用可以通过Thread构造方法赋值。
    由此看就比较明显了,要线程实现任务,要么直接重写run()方法,要么传入Runnable引用。

    继承Thread

    声明MyThread继承自Thread,并重写run()方法

        static class MyThread extends Thread {
            @Override
            public void run() {
                System.out.println("thread running by extends...");
            }
        }
        private static void startThreadByExtends() {
            MyThread t2 = new MyThread();
            t2.start();
        }
    

    生成Thread引用后,调用start()方法开启线程。

    实现Runnable

    先构造Runnable,再将Runnable引用传递给Thread。

        private static void startThreadByImplements() {
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    System.out.println("thread running by implements...");
                }
            };
    
            Thread t1 = new Thread(runnable);
            t1.start();
        }
    

    生成Thread引用后,调用start()方法开启线程。

    停止线程

    线程开启后,被CPU调度后执行run()方法,该方法执行完毕线程正常退出。当然也可以在run()方法执行途中退出该方法(设置标记位,满足条件即退出),该线程也将停止。若是run()方法里正在Thread.sleep(xx)、Object.wait()等方法,可以使用interrupt()方法中断线程。

        private static void stopThread() {
            MyThread t2 = new MyThread();
            t2.start();
    
            //中断线程
            t2.interrupt();
    
            //已废弃
            t2.stop();
        }
    

    更加完整的测试说明请移步:Java “优雅”地中断线程

    3、线程的交互

    硬件层面

    先来看看CPU和主存的交互:


    image.png

    CPU运算速度远远高于访问主存的速度,也就是说,当CPU需要计算如下表达式:

    int a = a + 1;
    

    首先从主存里拿到a的值,访问主存的过程中CPU是等待状态,当从主存拿到a的值后才进行运算。这个过程显然很浪费CPU的时间,因此在主存与CPU之间增加了高速缓存,顾名思义,当拿到a的值后,放到高速缓存,下次再次访问a的时候先去看看缓存里是否有,有的话直接拿到放到寄存器里,最后按照一定的规则将改变后的a的值刷新到主存里。
    访问速度:寄存器-->高速缓存-->主存,CPU在寻找值的时候先找寄存器,再到高速缓存,最后到主存。
    你可能已经发现问题了,如下代码:

    int a = 1;
    int a++;
    

    线程A、线程B分别执行上述代码,假设线程A被CPU1调度,线程B被CPU2调度。线程A、B分别执行a = 1,此时CPU1、CPU2的高速缓存分别存放着a=1,当线程A执行a++时发现高速缓存有值于是直接拿出来计算,结果是:a=2。
    当线程B执行时同样的从高速缓存获取值来计算,结果是:a=2。
    最后高速缓存将修改后的值回写的主存,结果是a=2。
    这样的结果不是我们愿意看到的,CPU针对此种情况设计了一套同步高速缓存+主存的机制:MESI(缓存一致性协议)
    该协议约定了各个CPU的高速缓存间与主存的配合,尽量保证缓存数据是一致的。但是由于StoreBuffer/InvalidateQueue的存在,还需要配合Volatile使用。
    有关Volatile详细解析请移步:真正理解Java Volatile的妙用

    软件层面

    由于寄存器、高速缓存的存在,让我们有种感觉:每个线程都拥有自己的本地内存。
    实际上,JVM设计了JMM(Java Memory Model Java内存模型):


    image.png

    本地内存是个虚拟概念,如下代码:

        static Integer integer = new Integer(0);
        
        public static void main(String args[]) {
    
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    integer = 5;
                }
            });
    
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    integer = 6;
                }
            });
    
            t1.start();
            t2.start();
        }
    

    integer 在主存中只有一份,可能还存在于寄存器、高速缓存等地方,这些地方对应的是本地内存。而不是每个线程又重新复制了一份数据。

    再看看一段代码:

        static boolean flag = false;
        static int a = 0;
    
        public static void main(String args[]) {
    
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;          //1
                    flag = true;    //2
                }
            });
    
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    if (flag) {     //3
                        a = 2;      //4
                    }
                }
            });
    
            t1.start();
            t2.start();
        }
    

    若是线程1先执行完,线程2再执行,结果没问题。若是两个线程同时执行,由于//1 //2之间没有依赖关系,编译器/处理器 可能会对//1 //2交换位置,这就是指令重排。如此之后,有可能执行顺序是:2->3->4->1,还有可能是其它顺序,最终的结果是不可控的。

    线程交互的核心

    从上述的软件层面、硬件层面分析可知,线程1、线程2、线程3各自的本地内存对其它线程是不可见的;多个线程写入主存时可能会存在脏数据;指令重排导致结果不可控。
    多线程交互需要解决上述三个问题,这三个问题也是线程并发的核心:

    1、可见性
    2、原子性
    3、有序性

    上述三者既是并发核心,也是基础,只有满足了三者,线程并发的共享变量结果才是可控的。
    我们熟知的锁、Volatile等是针对三者中的某个或者全部提出的解决方案。

    互斥与同步

    互斥的由来

    要满足并发的三个条件,想想该怎么做呢?
    先来看看原子性,既然多线程同时访问共享变量容易出问题,那么想到的是大家排队来访问它,当其中一个线程(A)在访问时,其它线程不能访问,并排队等待A线程执行完毕后,等待中的线程再次尝试访问共享变量,我们把操作共享变量的代码所在的区域称为临界区,共享变量称为临界资源。

    //临界区
        {
            a = 5;
            b = 6;
            c = a;
        }
    

    如上面的代码,多个线程不能同时访问临界区。
    这种访问方式称为:互斥。
    也就是说多个线程互斥地访问临界区可以实现操作的原子性。

    同步的由来

    临界区内的操作的共享变量在不同的线程可能有不一样的处理,如下代码:

    //伪代码
        int a = 0;
        //线程1执行
        private void add() {
            while(true) {
                if (a < 10)
                    a++;
            }
        }
    
        //线程2执行
        private void sub() {
            while(true) {
                if (a > 0)
                    a--;
            }
        }
    

    线程1、线程2都对变量a进行了操作,两者都依赖a的值做一些操作。
    线程1判断如果a<10,则a需要自增;线程2判断如果a>0,则a需要自减。
    线程1、线程2分别不断地去检查a的值看是否满足条件再做进一步操作,这么做没问题,但是效率太低。如果线程1、线程2检查到不满足条件先停下来等待,当满足条件时由对方通知自己,这样子就不用傻乎乎地每次跑去问a是多少了,极大提升了效率。
    因此,交互变成这样子:

    //伪代码
        int a = 0;
        //线程1执行
        private void add() {
            while(true) {
                if (a < 10)
                    a++;
                else
                    //等待,并通知线程2
            }
        }
    
        //线程2执行
        private void sub() {
            while(true) {
                if (a > 0)
                    a--;
                else
                    //等待,并通知线程1
            }
        }
    

    这么说流程有点枯燥,我们用个小比喻类比一下:
    用小明表示线程1、小刚表示线程2,小明要发一批集装箱,先把箱子拿到库房外的空地上,空地面积有限,最多只能放10个箱子,等待小刚过来拿货。

    1、刚开始小刚发现空地没货,于是等待小明通知。小明发现没货,开始放货。
    2、小明发现空地上还可以放箱子,于是继续放。
    3、小明发现箱子已经放了10个,空地占满了,于是就休息下来不再放了,并打电话告诉小刚,我的货够了,你快点过来拿货吧。
    4、小刚收到通知后,过来拿货,一直拿,当发现货拿完之后,就不再拿了,并打电话告诉小明,货拿完了,你快放货吧。

    于是整个流程简述:小明放了10个箱子就等待小刚拿,小刚拿完之后通知小明继续放。值得注意的是:上述是批量放了箱子,再批量拿箱子,并没有拿一个放一个。关于这个问题,后面细说
    又因为小明、小刚都依赖于箱子的个数做事,通过上面对互斥的分析,我们知道需要将这部分操作包裹在临界区里进行互斥访问。

    我们把上面的交互过程称之为:同步

    同步与互斥关系

    可以看出,同步是在互斥的基础上增加了等待-通知机制,实现了对互斥资源的有序访问,因此同步本身已经实现了互斥。

    同步是种复杂的互斥
    互斥是种特殊的同步

    解释了互斥、同步概念,那么该这么实现呢?
    接下来系列文章将重点分析系统提供的机制是如何实现可见性、原子性、有序性的以及互斥、同步与三者的关系。

    下篇文章:聊聊Unsafe的作用及其用法。

    您若喜欢,请点赞、关注,您的鼓励是我前进的动力

    持续更新中,和我一起步步为营系统、深入学习Android

    相关文章

      网友评论

        本文标题:Java 线程基础

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