美文网首页
Java并发编程(二) - 线程基础

Java并发编程(二) - 线程基础

作者: 未子涵 | 来源:发表于2020-10-13 21:48 被阅读0次

    并发不一定要依赖多线程(如PHP中很常见的多进程并发),但是在Java里面谈论并发,大多数都与线程脱不开关系,因此,就让我们从Java线程在虚拟机中的实现开始讲起。

    线程的实现

    主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理。我们注意到Thread类与大部分的Java API有着显著差别,它的所有关键方法都是声明为Native的。这就说明,有关线程的操作,底层都是与平台相关的。

    实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。

    1.使用内核线程实现

    内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。

    程序一般不会直接使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程。每个轻量级进程都由一个内核线程支持,这种关系称为 一对一的线程模型

    图1-轻量级进程与内核线程之间1-1的关系.jpg
    轻量级进程的局限性
    • 效率受限:因为有系统调用,而系统调用的代价相对较高,需要在用户态和内核态中来回切换。
    • 数量有限:轻量级进程需要内核线程来支持,因此轻量级进程会消耗一定的内核资源,因此一个系统支持轻量级进程的数量是有限的。

    2.使用用户线程实现

    广义

    从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT)。从这个定义上来看,轻量级进程也属于用户线程,但其实现始终是建立在内核线程之上的,许多操作都要进行系统调用,效率会受限。

    狭义
    • 狭义上的用户线程指的是完全建立在用户空间的线程库上
    • 其建立、同步、销毁和调度完全在用户态中完成
    • 如果程序实现得当,这种线程不需要切换到内核态,因此操作可以非常快速且低消耗
    • 可以支持规模更大的线程数量

    这种进程与用户线程之间1:N的关系称为一对多的线程模型

    图2-进程与用户线程之间1-N的关系.jpg
    用户线程的劣势

    不需要系统内核的支援,带给了用户线程一些优势,但同时也带来了一些劣势。

    • 所有的线程操作都需要用户程序自己处理,线程的创建、切换和调度都是需要考虑的问题。
    • 操作系统只把处理器资源分配到进程,那诸如”多处理器系统中如何将线程映射到其它处理器上“这类问题就很难处理,甚至不可能完成。
    • 最终导致使用用户线程实现的程序一般都比较复杂。

    除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、Ruby等语言都曾经使用过用户线程,最终又都放弃使用它

    3.使用用户线程加轻量级进程混合实现

    这是一种将内核线程与用户线程一起使用的实现方式。

    • 用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
    • 轻量级进程作为用户线程和内核线程之间的桥梁,这样可以使用内核线程提供的线程调度功能及处理器映射。
    • 用户线程的系统调用要通过轻量级进程来完成。

    在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,这就是 多对多的线程模型

    图3-用户线程与轻量级进程之间N-M的关系.jpg

    4.Java线程的实现

    • JDK1.2之前,是基于称为”绿色线程“的用户线程实现的
    • JDK1.2开始,替换为基于操作系统原生线程模型来实现,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同平台上没有办法达成一致

    对于Sun JDK来说,其Windows版与Linux版都是使用的”一对一的线程模型“实现的,一条Java线程就映射到一条轻量级进程之中。

    而在Solaris平台中,由于操作系统同时支持一对一及多对多的线程模型,因此在Solaris版的JDK中也对应提供了两个平台专有的虚拟机参数来明确指定虚拟机使用哪种线程模型。

    Java线程调度

    线程调度是指系统为线程分配处理器使用权的过程,主要有:协同式线程调度抢占式线程调度

    协同式

    线程执行时间由自己控制:本身任务结束后,主动通知系统切换线程。

    优点
    • 实现简单
    • 切换操作对线程自己是可知的,所以没有线程同步问题
    缺点
    • 线程执行时间不可控,程序有可能一直阻塞
    • 一个进程坚持不让出CPU时间时,可能会导致整个系统崩溃

    抢占式

    线程执行时间由系统分配,线程切换也由系统控制。

    线程可以通过 Thread.yield() 主动让出执行时间,但却无法主动获取执行时间。

    优点
    • 线程执行时间可控,不会有一个线程导致整个进程阻塞的问题
    • 一个进程出了问题,可以通过任务管理器把进程“杀掉”,而不至于导致系统崩溃

    给系统的线程调度提“建议”

    虽然Java线程调度是系统自动完成的,但在孰多孰少上,我们也可以给系统提“建议”。这个操作可以通过设置 线程优先级 来完成。

    如何理解“线程优先级”
    • Java定义了10个线程优先级:Thread.MIN_PRIORITY ~ Thread.MAX_PRIORITY
    • 当两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行
    • 线程优先级并不是“太靠谱”,其根本原因是:Java线程最终是映射到内核线程上来实现的,所以最终的调度权其实掌握在操作系统手中
    • 不能通过线程优先级完全准确地判断一组状态均为Ready地线程将会先执行哪一个

    扩展:线程优先级“不靠谱”的直接原因

    • 一些系统中不同的优先级实际会变得相同
      虽然多数操作系统都提供了线程优先级的概念,但并不一定都能与Java线程的优先级一一对应,比如Solaris有“2的32次方”种优先级,而Windows只有7种。当操作系统的线程优先级少于Java的线程优先级时,就不得不出现几个优先级相同的情况了。
    • 优先级可能会被系统自行改变
      比如Windows中的“优先级推进器”(可以被关闭),它的大致作用是当系统发现一个线程执行得特别”勤奋努力“的话,可能会越过线程优先级去为它分配执行时间。
    Java线程优先级与Windows线程优先级之间的对应关系
    Java线程优先级 Windows线程优先级
    1(Thread.MIN_PRIORITY) THREAD_PRIORITY_LOWEST
    2 THREAD_PRIORITY_LOWEST
    3 THREAD_PRIORITY_BELOW_NARMAL
    4 THREAD_PRIORITY_BELOW_NARMAL
    5(Thread.NORMAL_PRIORITY) THREAD_PRIORITY_NORMAL
    6 THREAD_PRIORITY_ABOVE_NORMAL
    7 THREAD_PRIORITY_ABOVE_NORMAL
    8 THREAD_PRIORITY_HIGHEST
    9 THREAD_PRIORITY_HIGHEST
    10(Thread.MAX_PRIORITY) THREAD_PRIORITY_CRITICAL

    状态转换

    Java定义了以下5种线程状态,它们在遇到特定事件时将会互相转换,但是在任意一个时间点,一个线程只能有且只有其中一种状态

    1.新建(New)

    创建后尚未启动

    2.运行(Runnable)

    Runnable 包括了操作系统线程状态中的 RunningReady ,也就是可能正在执行,也可能正在等待 CPU 为它分配执行时间。

    3-1.无限期等待(Waiting)

    不会被分配 CPU 执行时间,直到被其他线程显式地唤星。以下方法可以让线程进入该状态:

    • 没有设置 Timeout 的 Object.wait()
    • 没有设置 Timeout 的 Thread.join()
    • LockSupport.park()
    3-2.限期等待(Timed Waiting)

    不会被分配 CPU 执行时间,但也不是非要等待被其他线程显式地唤醒,在一定时间之后会由系统自动唤醒。以下方法可以让线程进入该状态:

    • Thread.sleep()
    • 设置了 Timeout 的 Object.wait()
    • 设置了 Timeout 的 Thread.join()
    • LockSupport.parkNanos()
    • LockSupport.parkUntil()
    4.阻塞(Blocked)

    线程被阻塞了。在程序将进入同步区域的时候,线程将进入这种状态。

    ”阻塞状态“与”等待状态“的区别是:</br>
    ”阻塞状态“在等待获取到一个排他锁,这将在另一个线程放弃这个排他锁时发生;</br>
    ”等待状态“在等待一段时间,或者唤醒动作的发生。

    5.结束(Terminated)

    线程已结束执行。

    线程状态转换关系

    线程状态转换关系.jpg

    线程状态的基本操作

    join()

    如果某个线程的输入需要依赖另一个线程的输出,则可以通过 join() 实现。比如在线程A中调用线程B的join方法,意思就是:线程A会等待线程B执行结束后再继续。可见,join() 可以简单地实现多线程排序。

    我们看下面这段测试代码:

    public class JoinTester {
        public static void main(String[] args) {
            Thread previousThread = Thread.currentThread();
            for (int i = 1; i <= 10; i++) {
                Thread curThread = new JoinThread(previousThread);
                curThread.start();
                previousThread = curThread;
            }
    
            System.out.println(Thread.currentThread().getName() + " terminated.");
        }
    
        static class JoinThread extends Thread {
            private Thread thread;
    
            public JoinThread(Thread thread) {
                this.thread = thread;
            }
    
            @Override
            public void run() {
                try {
                    // 试试打开下面两行代码看看运行结果
    //              Thread.sleep(10);   // 这里完全可以不用sleep,只是为了更容易看到效果
    //              System.out.println("I'm [" + Thread.currentThread().getName() + "], " +
    //                      "I need to wait [" + thread.getName()  +"] to terminate.");
                    thread.join();
                    System.out.println(Thread.currentThread().getName() + " terminated.");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    

    其输出如下:

    main terminated.
    Thread-0 terminated.
    Thread-1 terminated.
    Thread-2 terminated.
    Thread-3 terminated.
    Thread-4 terminated.
    Thread-5 terminated.
    Thread-6 terminated.
    Thread-7 terminated.
    Thread-8 terminated.
    Thread-9 terminated.
    

    可见,确实实现了排序的效果,其实,细心的你可以再多思考一下:这整个运行过程的细节是怎样的?实际的排序效果是何时发生的?

    为了更准确地理解整个运行过程,可以将上述代码中被注释的两行打开,再看看运行结果:

    main terminated.
    I'm [Thread-0], I need to wait [main] to terminate.
    Thread-0 terminated.
    I'm [Thread-2], I need to wait [Thread-1] to terminate.
    I'm [Thread-1], I need to wait [Thread-0] to terminate.
    Thread-1 terminated.
    Thread-2 terminated.
    I'm [Thread-4], I need to wait [Thread-3] to terminate.
    I'm [Thread-3], I need to wait [Thread-2] to terminate.
    Thread-3 terminated.
    I'm [Thread-9], I need to wait [Thread-8] to terminate.
    I'm [Thread-5], I need to wait [Thread-4] to terminate.
    Thread-4 terminated.
    I'm [Thread-8], I need to wait [Thread-7] to terminate.
    I'm [Thread-7], I need to wait [Thread-6] to terminate.
    I'm [Thread-6], I need to wait [Thread-5] to terminate.
    Thread-5 terminated.
    Thread-6 terminated.
    Thread-7 terminated.
    Thread-8 terminated.
    Thread-9 terminated.
    

    可见,join() 并非是说“线程B执行结束后,才开始执行线程A( run() 方法)”,其实线程A已经运行起来了,只是它在等待B运行结束而已。也就是说,真正被有序执行的代码,是 join() 之后的那部分代码,而 join() 之前的所有代码,各个线程都是并行的,没有固定的执行顺序。

    我们再看一段示例代码:

    public class JoinTester2 {
        public static void main(String[] args) {
            int size = 5;
            MyThread[] arr = new MyThread[size];
            for (int i = 0; i < size; i++) {
                arr[i] = new MyThread(i);
                arr[i].start();
            }
            String output = "";
            try {
                arr[0].join();
                output += arr[0].get();
                arr[1].join();
                output += arr[1].get();
                arr[2].join();
                output += arr[2].get();
                arr[3].join();
                output += arr[3].get();
                arr[4].join();
                output += arr[4].get();
                System.out.println("Result: " + output);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        static class MyThread extends Thread {
            private int i;
    
            public MyThread(int i) {
                setName("T-" + i);
                this.i = i;
            }
    
            @Override
            public void run() {
                this.i++;
                System.out.println("I'm [" + getName() + "], i is now " + this.i);
            }
    
            public int get() {
                return this.i;
            }
        }
    }
    

    输出如下:

    I'm [T-0], i is now 1
    I'm [T-1], i is now 2
    I'm [T-3], i is now 4
    I'm [T-4], i is now 5
    I'm [T-2], i is now 3
    Result: 12345
    

    可见,线程体的执行(i自增)是完全并行的,而有序输出自增后的结果,这里的“有序”是在最后的结果输出阶段才做了排序拼接。

    sleep() 和 wait()

    sleep()wait() 都是让线程等待,但它们有以下区别:

    • wait() 是 Object 的实例方法;sleep() 是 Thread 的静态方法。
    • wait() 必须在“同步方法”或“同步块”中使用,也就是必须已经获得对象锁;而 sleep() 没有该限制。
    • wait() 会释放对象锁,并使该线程进入等待池;而 sleep() 只会让出 CPU 时间,并不会释放已持有的对象锁。
    • wait() 不带参数时,必须被唤醒,否则会永远等待下去;而 sleep() 没有无参版本,也就是不需要被唤醒,也不会永远等待。

    我们看下面这段代码:

    public class SleepWaitTester {
        private static byte[] lock = new byte[0];
    
        public static void main(String[] args) {
            for (int i = 0; i < 2; i++) {
                new MyThread().start();
            }
        }
    
        static class MyThread extends Thread {
            @Override
            public void run() {
                doWork();
            }
    
            private void doWork() {
                synchronized (lock) {
                    try {
                        System.out.println(getName() + " start running");
                        Thread.sleep(1000); // 换成lock.wait(1000)试试看
                        System.out.println(getName() + " stop running");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
    

    当使用 sleep() 时,运行结果如下:

    Thread-0 start running
    Thread-0 stop running
    Thread-1 start running
    Thread-1 stop running
    

    如果将 sleep() 换成 wait() ,运行结果就成了:

    Thread-0 start running
    Thread-1 start running
    Thread-1 stop running
    Thread-0 stop running
    
    yield()

    yield() 定义如下:

    public static native void yield();
    

    可见,yield() 其实是一个native方法,它有以下特点:

    • 它会使当前线程让出 CPU ,但这并不意味着当前线程就不执行了,如果下一次竞争资源时又获得了 CPU 时间,就会继续执行
    • 让出的时间片只会分配给与当前线程相同优先级的线程,而 sleep() 让出的时间片允许任何线程竞争。

    来看以下代码:

    public class YieldTester {
    
        public static void main(String[] args) {
            for (int i = 0; i < 5; i++) {
                new MyThread().start();
            }
        }
    
        static class MyThread extends Thread {
            @Override
            public void run() {
                doWork();
            }
    
            private void doWork() {
                System.out.println(getName() + " start running");
                yield();
                System.out.println(getName() + " stop running");
            }
        }
    }
    

    运行结果为:

    Thread-0 start running
    Thread-1 start running
    Thread-2 start running
    Thread-0 stop running
    Thread-3 start running
    Thread-1 stop running
    Thread-4 start running
    Thread-3 stop running
    Thread-4 stop running
    Thread-2 stop running
    

    守护线程(Deamon Thread)

    接下来,我们再来了解一下“守护线程”的概念。Java将线程分为 User线程Daemon线程 两种。 Daemon 线程即守护线程。关于守护线程,有以下要点:

    • 所谓守护线程就是运行在程序后台的线程
    • Daemon thread在Java里面的定义是,如果虚拟机中只有Daemon thread 在运行,则虚拟机退出。
      • 通常Daemon线程用来为User线程提供某些服务。程序的main()方法线程是一个User线程,由它产生的线程默认都是User线程。当所有的User线程结束后,JVM才会结束。
    • 通过在一个线程对象上调用setDaemon(true),可以将user线程创建的线程明确地设置成Daemon线程。例如,时钟处理线程、idle线程、垃圾回收线程、屏幕更新线程等,都是Daemon线程。
      • 需要注意的是,setDaemon()方法必须在调用线程的start()方法之前调用。一旦一个线程开始执行(如,调用了start()方法),它的daemon状态不能再修改。通过方法isDaemon()可以知道一个线程是否Daemon线程。
    • 通常新创建的线程会从创建它的线程那里继承daemon状态,除非明确地在线程对象上调用setDaemon方法来改变daemon状态。
    • Deamon线程的finally块,不保证执行

    通过下面一段代码,可以很清楚地说明daemon的作用。

    public class DeamonThreadTest {
        public static void main(String[] args) {
            // 验证一:Deamon线程的finally块,不保证执行,
            // 若执行到finally之前所有user线程都结束,则deamon线程也会随着jvm退出而结束,不会执行finally
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        System.out.println("DeamonThread finally run.");
                    }
                }
            });
            thread.setDaemon(true);
            thread.start();
    
            // 验证二:main线程结束,deamon线程即终止,不会等待执行完毕
            Runnable r = new Runnable() {
                public void run() {
                    for (int time = 10; time > 0; --time) {
                        System.out.println("Time #" + time);
                        try {
                            Thread.sleep(200);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            };
    
            Thread t = new Thread(r);
            t.setDaemon(true);  // try to set this to "false" and see what happens
            t.start();
    
            System.out.println("Main thread waiting...");
            try {
                Thread.sleep(600);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Main thread exited.");
        }
    }
    

    运行结果是:

    Main thread waiting...
    Time #10
    Time #9
    Time #8
    Main thread exited.
    

    从这个结果验证了两点:一、Deamon线程的finally块,不保证执行。二、当所有User线程(这里的main线程)结束时,Jvm即退出,不会等待 deamon 线程执行完毕。

    我们将线程 t 设置为 User 线程(即setDeamon为false)后,再运行一下:

    Main thread waiting...
    Time #10
    Time #9
    Time #8
    Main thread exited.
    Time #7
    Time #6
    Time #5
    Time #4
    Time #3
    Time #2
    Time #1
    DeamonThread finally run.
    

    这一次,作为 User 线程时,就能数完10个数字了。

    Java线程总结

    最后,我们来总结一下关于Java线程的基础知识点:

    • 线程实现:1对1的线程模型(即使用内核线程实现)
    • 线程调度:抢占式
    • 线程优先级并不太靠谱:你只能给系统”建议“,而无法”命令“它
    • 在任意一个时间点,一个线程只能有且只有5种线程状态中的一种

    相关文章

      网友评论

          本文标题:Java并发编程(二) - 线程基础

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