分享

Java并发入门原理教程(二)

 夜猫速读 2022-05-05 发布于湖北

1. 前言

本节对 yield 方法进行深入的剖析,主要内容点如下:

  • 首先要了解什么是 CPU 执行权,因为 yield 方法与 CPU 执行权息息相关;

  • 了解 yield 方法的作用,要明确 yield 方法的使用所带来的运行效果;

  • 了解什么是 native 方法,由于 yield 方法是 native 方法的调用,在学习 yield 方法之前,要了解什么是 native 方法;

  • 掌握 yield 方法如何使用,这是本节知识点的重中之重,一定要着重学习;

  • 了解 yield 方法和 sleep 方法的区别,进行对比记忆,更有助于掌握该方法的独有特性。

2. 什么是 CPU 执行权

我们知道操作系统是为每个线程分配一个时间片来占有 CPU 的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,这里所说的 “自己占有的时间片” 即 CPU 分配给线程的执行权。

那进一步进行探究,何为让出 CPU 执行权呢?当一个线程通过某种可行的方式向操作系统提出让出 CPU 执行权时,就是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,主动放弃剩余的时间片,并在合适的情况下,重新获取新的执行时间片。

3. yield 方法的作用

方法介绍:Thread 类中有一个静态的 yield 方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的 CPU 使用权。

public static native void yield();
代码块1

Tips:从这个源码中我们能够看到如下两点要点:

  1. yield 方法是一个静态方法,静态方法的特点是可以由类直接进行调用,而不需要进行对象 new 的创建,调用方式为 Thread.yield ()。

  2. 该方法除了被 static 修饰,还被 native 修饰,那么进入主题,什么是 native 方法呢?我们继续来看下文的讲解。

抽象地讲,一个 Native Method 就是一个 Java 调用的非 Java 代码的接口。一个 Native Method 是这样一个 Java 的方法:该方法的实现由非 java 语言实现。

简单的来说,native 方法就是我们自己电脑的方法接口,比如 Windows 电脑会提供一个 yield 方法,Linux 系统的电脑也同样会提供一个 yield 方法,本地方法,可以理解为操作调用操作系统的方法接口。

作用:暂停当前正在执行的线程对象(及放弃当前拥有的 cup 资源),并执行其他线程。yield () 做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。

目的:yield 即 “谦让”,使用 yield () 的目的是让具有相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证 yield () 达到谦让目的,因为放弃 CPU 执行权的线程还有可能被线程调度程序再次选中。

4. yield 方法如何使用

为了更好的了解 yield 方法的使用,我们首先来设计一个使用的场景。场景设计

  • 创建一个线程,线程名为 threadOne;

  • 打印一个数,该数的值为从 1 加到 10000000 的和;

  • 不使用 yield 方法正常执行,记录总的执行时间;

  • 加入 yield 方法,再次执行程序;

  • 再次记录总执行时间。

期望结果:未加入 yield 方法之前打印的时间 < 加入 yield 方法之后的打印时间。因为 yield 方法在执行过程中会放弃 CPU 执行权并从新获取新的 CPU 执行权。

代码实现 - 正常执行

public class DemoTest extends Thread {
    @Override
    public void run() {
        Long start = System.currentTimeMillis();
        int count = 0;
        for (int i = 1; i <= 10000000; i++) {
             count = count + i;
        }
        Long end = System.currentTimeMillis();
        System.out.println("总执行时间:"+ (end-start) + " 毫秒, 结果 count = " + count);
    }
    public static void main(String[] args) throws InterruptedException {
        DemoTest threadOne = new DemoTest();
        threadOne. start();
    }
}

执行结果验证

总执行时间: 6 毫秒.
代码块1

代码实现 - yield 执行

public class DemoTest extends Thread {
    @Override
    public void run() {
        Long start = System.currentTimeMillis();
        int count = 0;
        for (int i = 1; i <= 10000000; i++) {
             count = count + i;
             this.yield(); // 加入 yield 方法
        }
        Long end = System.currentTimeMillis();
        System.out.println("总执行时间:"+ (end-start) + " 毫秒. ");
    }
    public static void main(String[] args) throws InterruptedException {
        DemoTest threadOne = new DemoTest();
        threadOne. start();
    }
}

执行结果验证

总执行时间: 5377 毫秒.
代码块1

从执行的结果来看,与我们对 yield 方法的理解和分析完全相符,请同学也进行代码的编写和运行,加深学习印象。当加入 yield 方法执行时,线程会放弃 CPU 的执行权,并等待再次获取新的执行权,所以执行时间上会更加的长。

5. yield 方法和 sleep 方法的区别

  • sleep () 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;

  • yield () 方法只会给相同优先级或更高优先级的线程以运行的机会;

  • 线程执行 sleep () 方法后转入阻塞 (blocked) 状态,而执行 yield () 方法后转入就绪 (ready) 状态;

  • sleep () 方法声明会抛出 InterruptedException, 而 yield () 方法没有声明任何异常;

  • sleep () 方法比 yield () 方法具有更好的移植性 (跟操作系统 CPU 调度相关)。

6. 小结

在实际的开发场景中,yield 方法的使用场景比较少,但是对于并发原理知识的学习过程,对 yield 方法的了解非常重要,有助于同学了解不同状态下的线程的不同状态。本节要重点掌握 yield 方法的作用以及如何使用 yield 方法。

线程上下文切换与死锁

1. 前言

本节内容主要是对死锁进行深入的讲解,具体内容点如下:

  • 理解线程的上下文切换,这是本节的辅助基础内容,从概念层面进行理解即可;

  • 了解什么是线程死锁,在并发编程中,线程死锁是一个致命的错误,死锁的概念是本节的重点之一;

  • 了解线程死锁的必备 4 要素,这是避免死锁的前提,了解死锁的必备要素,才能找到避免死锁的方式;

  • 掌握死锁的实现,通过代码实例,进行死锁的实现,深入体会什么是死锁,这是本节的重难点之一;

  • 掌握如何避免线程死锁,我们能够实现死锁,也可以避免死锁,这是本节内容的核心。

2. 理解线程的上下文切换

概述:在多线程编程中,线程个数一般都大于 CPU 个数,而每个 CPU 同一时-刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。定义:当前线程使用完时间片后,就会处于就绪状态并让出 CPU,让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程。

问题点解析:那么就有一个问题,让出 CPU 的线程等下次轮到自己占有 CPU 时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场, 当再次执行时根据保存的执行现场信息恢复执行现场。

线程上下文切换时机: 当前线程的 CPU 时间片使用完或者是当前线程被其他线程中断时,当前线程就会释放执行权。那么此时执行权就会被切换给其他的线程进行任务的执行,一个线程释放,另外一个线程获取,就是我们所说的上下文切换时机。

3. 什么是线程死锁

定义:死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。如上图所示死锁状态,线程 A 己经持有了资源 2,它同时还想申请资源 1,可是此时线程 B 已经持有了资源 1 ,线程 A 只能等待。

反观线程 B 持有了资源 1 ,它同时还想申请资源 2,但是资源 2 已经被线程 A 持有,线程 B 只能等待。所以线程 A 和线程 B 就因为相互等待对方已经持有的资源,而进入了死锁状态。

4. 线程死锁的必备要素

  • 互斥条件:进程要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待;

  • 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放,如 yield 释放 CPU 执行权);

  • 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放;

  • 循环等待条件:指在发生死锁时,必然存在一个线程请求资源的环形链,即线程集合 {T0,T1,T2,…Tn}中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,以此类推,Tn 正在等待己被 T0 占用的资源。

如下图所示

5. 死锁的实现

为了更好的了解死锁是如何产生的,我们首先来设计一个死锁争夺资源的场景。场景设计

  • 创建 2 个线程,线程名分别为 threadA 和 threadB;

  • 创建两个资源, 使用 new Object () 创建即可,分别命名为 resourceA 和 resourceB;

  • threadA 持有 resourceA 并申请资源 resourceB;

  • threadB 持有 resourceB 并申请资源 resourceA ;

  • 为了确保发生死锁现象,请使用 sleep 方法创造该场景;

  • 执行代码,看是否会发生死锁。

期望结果:发生死锁,线程 threadA 和 threadB 互相等待。

Tips:此处的实验会使用到关键字 synchronized,后续小节还会对关键字 synchronized 单独进行深入讲解,此处对 synchronized 的使用仅仅为初级使用,有 JavaSE 基础即可。

实例

public class DemoTest{
    private static  Object resourceA = new Object();//创建资源 resourceA
    private static  Object resourceB = new Object();//创建资源 resourceB
    public static void main(String[] args) throws InterruptedException {
        //创建线程 threadA
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resourceA) {
                    System.out.println(Thread.currentThread().getName() + "获取 resourceA。");
                    try {
                        Thread.sleep(1000); // sleep 1000 毫秒,确保此时 resourceB 已经进入run 方法的同步模块
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "开始申请 resourceB。");
                    synchronized (resourceB) {
                        System.out.println (Thread.currentThread().getName() + "获取 resourceB。");
                    }
                }
            }
        });
        threadA.setName("threadA");
        //创建线程 threadB
        Thread threadB = new Thread(new Runnable() { //创建线程 1
            @Override
            public void run() {
                synchronized (resourceB) {
                    System.out.println(Thread.currentThread().getName() + "获取 resourceB。");
                    try {
                        Thread.sleep(1000); // sleep 1000 毫秒,确保此时 resourceA 已经进入run 方法的同步模块
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "开始申请 resourceA。");
                    synchronized (resourceA) {
                        System.out.println (Thread.currentThread().getName() + "获取 resourceA。");
                    }
                }
            }
        });
        threadB.setName("threadB");
        threadA. start();
        threadB. start();
    }
}

代码讲解

  • 从代码中来看,我们首先创建了两个资源 resourceA 和 resourceB;

  • 然后创建了两条线程 threadA 和 threadB。threadA 首先获取了 resourceA ,获取的方式是代码 synchronized (resourceA) ,然后沉睡 1000 毫秒;

  • 在 threadA 沉睡过程中, threadB 获取了 resourceB,然后使自己沉睡 1000 毫秒;

  • 当两个线程都苏醒时,此时可以确定 threadA 获取了 resourceA,threadB 获取了 resourceB,这就达到了我们做的第一步,线程分别持有自己的资源;

  • 那么第二步就是开始申请资源,threadA 申请资源 resourceB,threadB 申请资源 resourceA 无奈 resourceA 和 resourceB 都被各自线程持有,两个线程均无法申请成功,最终达成死锁状态。

执行结果验证

threadA 获取 resourceA。
threadB 获取 resourceB。
threadA 开始申请 resourceB。
threadB 开始申请 resourceA。
代码块1234

看下验证结果,发现已经出现死锁,threadA 申请 resourceB,threadB 申请 resourceA,但均无法申请成功,死锁得以实验成功。

6. 如何避免线程死锁

要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,学过操作系统的读者应该都知道,目前只有请求并持有和环路等待条件是可以被破坏的。

造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可避免死锁。

我们依然以第 5 个知识点进行讲解,那么实验的需求和场景不变,我们仅仅对之前的 threadB 的代码做如下修改,以避免死锁。

代码修改

Thread threadB = new Thread(new Runnable() { //创建线程 1
            @Override
            public void run() {
                synchronized (resourceA) { //修改点 1
                    System.out.println(Thread.currentThread().getName() + "获取 resourceB。");//修改点 3
                    try {
                        Thread.sleep(1000); // sleep 1000 毫秒,确保此时 resourceA 已经进入run 方法的同步模块
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "开始申请 resourceA。");//修改点 4
                    synchronized (resourceB) { //修改点 2
                        System.out.println (Thread.currentThread().getName() + "获取 resourceA。"); //修改点 5
                    }
                }
            }
        });

请看如上代码示例,有 5 个修改点:

  • 修改点 1 :将 resourceB 修改成 resourceA;

  • 修改点 2 :将 resourceA 修改成 resourceB;

  • 修改点 3 :将 resourceB 修改成 resourceA;

  • 修改点 4 :将 resourceA 修改成 resourceB;

  • 修改点 5 :将 resourceA 修改成 resourceB。

请读者按指示修改代码,并从新运行验证。

修改后代码讲解

  • 从代码中来看,我们首先创建了两个资源 resourceA 和 resourceB;

  • 然后创建了两条线程 threadA 和 threadB。threadA 首先获取了 resourceA ,获取的方式是代码 synchronized (resourceA) ,然后沉睡 1000 毫秒;

  • 在 threadA 沉睡过程中, threadB 想要获取 resourceA ,但是 resourceA 目前正被沉睡的 threadA 持有,所以 threadB 等待 threadA 释放 resourceA;

  • 1000 毫秒后,threadA 苏醒了,释放了 resourceA ,此时等待的 threadB 获取到了 resourceA,然后 threadB 使自己沉睡 1000 毫秒;

  • threadB 沉睡过程中,threadA 申请 resourceB 成功,继续执行成功后,释放 resourceB;

  • 1000 毫秒后,threadB 苏醒了,继续执行获取 resourceB ,执行成功。

执行结果验证

threadA 获取 resourceA。
threadA 开始申请 resourceB。
threadA 获取 resourceB。
threadB 获取 resourceA。
threadB 开始申请 resourceB。
threadB 获取 resourceB。
代码块123456

我们发现 threadA 和 threadB 按照相同的顺序对 resourceA 和 resourceB 依次进行访问,避免了互相交叉持有等待的状态,避免了死锁的发生。

7. 小结

死锁是并发编程中最致命的问题,如何避免死锁,是并发编程中恒久不变的问题。掌握死锁的实现以及如果避免死锁的发生,是本节内容的重中之重。

守护线程与用户线程

1. 前言

本节内容主要是对守护线程与用户线程进行深入的讲解,具体内容点如下:

  • 了解守护线程与用户线程的定义及区别,使我们学习本节内容的基础知识点;

  • 了解守护线程的特点,是我们掌握守护线程的第一步;

  • 掌握守护线程的创建,是本节内容的重点;

  • 通过守护线程与 JVM 的退出实验,更加深入的理解守护线程的地位以及作用,为本节内容次重点;

  • 了解守护线程的作用及使用场景,为后续开发过程中提供守护线程创建的知识基础。

2. 守护线程与用户线程的定义及区别

Java 中的线程分为两类,分别为 daemon 线程(守护线程〉和 user 线程(用户线程)。

在 JVM 启动时会调用 main 函数, main 函数所在的线程就是一个用户线程,其实在 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程。

守护线程定义:所谓守护线程,是指在程序运行的时候在后台提供一种通用服务的线程。比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。

因此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程。反过来说,只要任何非守护线程还在运行,程序就不会终止。

用户线程定义:某种意义上的主要用户线程,只要有用户线程未执行完毕,JVM 虚拟机不会退出。

区别:在本质上,用户线程和守护线程并没有太大区别,唯一的区别就是当最后一个非守护线程结束时,JVM 会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响 JVM 的退出。

言外之意,只要有一个用户线程还没结束, 正常情况下 JVM 就不会退出。

3. 守护线程的特点

Java 中的守护线程和 Linux 中的守护进程是有些区别的,Linux 守护进程是系统级别的,当系统退出时,才会终止。

而 Java 中的守护线程是 JVM 级别的,当 JVM 中无任何用户进程时,守护进程销毁,JVM 退出,程序终止。总结来说,Java 守护进程的最主要的特点有:

  • 守护线程是运行在程序后台的线程;

  • 守护线程创建的线程,依然是守护线程;

  • 守护线程不会影响 JVM 的退出,当 JVM 只剩余守护线程时,JVM 进行退出;

  • 守护线程在 JVM 退出时,自动销毁。

4. 守护线程的创建

创建方式:将线程转换为守护线程可以通过调用 Thread 对象的 setDaemon (true) 方法来实现。

创建细节

  • thread.setDaemon (true) 必须在 thread.start () 之前设置,否则会跑出一个 llegalThreadStateException 异常。你不能把正在运行的常规线程设置为守护线程;

  • 在 Daemon 线程中产生的新线程也是 Daemon 的;

  • 守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。

线程创建代码示例

public class DemoTest {
    public static void main(String[] args) throws InterruptedException {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                //代码执行逻辑
            }
        });
        threadOne.setDaemon(true); //设置threadOne为守护线程
        threadOne. start();
    }
}

5. 守护线程与 JVM 的退出实验

为了更好的了解守护线程与 JVM 是否退出的关系,我们首先来设计一个守护线程正在运行,但用户线程执行完毕导致的 JVM 退出的场景。

场景设计

  • 创建 1 个线程,线程名为 threadOne;

  • run 方法线程 sleep 1000 毫秒后,进行求和计算,求解 1 + 2 + 3 + … + 100 的值;

  • 将线程 threadOne 设置为守护线程;

  • 执行代码,最终打印的结果;

  • 加入 join 方法,强制让用户线程等待守护线程 threadOne;

  • 执行代码,最终打印的结果。

期望结果

  • 未加入 join 方法之前,threadOne 不能执行求和逻辑,无打印输出,因为 main 函数线程执行完毕后,JVM 退出,守护线程也就随之死亡,无打印结果;

  • 加入 join 方法后,可以打印求和结果,因为 main 函数线程需要等待 threadOne 线程执行完毕后才继续向下执行,main 函数执行完毕,JVM 退出。

Tips:main 函数就是一个用户线程,main 方法执行时,只有一个用户线程,如果 main 函数执行完毕,用户线程销毁,JVM 退出,此时不会考虑守护线程是否执行完毕,直接退出。

代码实现 - 不加入 join 方法

public class DemoTest {
    public static void main(String[] args){
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                int sum = 0;
                for (int i = 1; i  <= 100; i++) {
                    sum = sum + i;
                }
                System.out.println("守护线程,最终求和的值为: " + sum);
            }
        });
        threadOne.setDaemon(true); //设置threadOne为守护线程
        threadOne. start();
        System.out.println("main 函数线程执行完毕, JVM 退出。");
    }
}
代码块12345678910111213141516171819202122

执行结果验证

main 函数线程执行完毕, JVM 退出。
代码块1

从结果上可以看到,JVM 退出了,守护线程还没来得及执行,也就随着 JVM 的退出而消亡了。

代码实现 - 加入 join 方法

public class DemoTest {
    public static void main(String[] args){
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                int sum = 0;
                for (int i = 1; i  <= 100; i++) {
                    sum = sum + i;
                }
                System.out.println("守护线程,最终求和的值为: " + sum);
            }
        });
        threadOne.setDaemon(true); //设置threadOne为守护线程
        threadOne. start();
        try {
            threadOne.join(); // 加入join 方法
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main 函数线程执行完毕, JVM 退出。");
    }
}

执行结果验证

守护线程,最终求和的值为: 5050
main 函数线程执行完毕, JVM 退出。
代码块12

从结果来看,守护线程不决定 JVM 的退出,除非强制使用 join 方法使用户线程等待守护线程的执行结果,但是实际的开发过程中,这样的操作是不允许的,因为守护线程,默认就是不需要被用户线程等待的,是服务于用户线程的。

6. 守护线程的作用及使用场景

作用:我们以 GC 垃圾回收线程举例,它就是一个经典的守护线程,当我们的程序中不再有任何运行的 Thread, 程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是 JVM 上仅剩的线程时,垃圾回收线程会自动离开。

它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。

应用场景

  • 为其它线程提供服务支持的情况,可选用守护线程;

  • 根据开发需求,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;

  • 如果一个正在执行某个操作的线程必须要执行完毕后再释放,否则就会出现不良的后果的话,那么这个线程就不能是守护线程,而是用户线程;

  • 正常开发过程中,一般心跳监听,垃圾回收,临时数据清理等通用服务会选择守护线程。

7. 小结

掌握用户线程和守护线程的区别点非常重要,在实际的工作开发中,对一些服务型,通用型的线程服务可以根据需要选择守护线程进行执行,这样可以减少 JVM 不可退出的现象,并且可以更好地协调不同种类的线程之间的协作,减少守护线程对高优先级的用户线程的资源争夺,使系统更加的稳定。

本节的重中之重是掌握守护线程的创建以及创建需要注意的事项,了解守护线程与用户线程的区别使我们掌握守护线程的前提。

ThreadLocal 的使用

1. 前言

本节内容主要是对 ThreadLocal 进行深入的讲解,具体内容点如下:

  • 了解 ThreadLocal 的诞生,以及总体概括,是学习本节知识的基础;

  • 了解 ThreadLocal 的作用,从整体层面理解 ThreadLocal 的程序作用,为本节的次重点;

  • 掌握 ThreadLocal set 方法的使用,为本节重点内容;

  • 掌握 ThreadLocal get 方法的使用,为本节重点内容;

  • 掌握 ThreadLocal remove 方法的使用,为本节重点内容;

  • 掌握多线程下的 ThreadLocal 的使用,为本节内容的核心。

2. ThreadLocal 概述

诞生:早在 JDK 1.2 的版本中就提供 java.lang.ThreadLocal,ThreadLocal 为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序。

概述:ThreadLocal 很容易让人望文生义,想当然地认为是一个 “本地线程”。其实,ThreadLocal 并不是一个 Thread,而是 Thread 的局部变量,也许把它命名为 ThreadLocalVariable 更容易让人理解一些。

当使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

总体概括:从线程的角度看,目标变量就象是线程的本地变量,这也是类名中 “Local” 所要表达的意思。

了解完 ThreadLocal 的总体介绍后,对其有了一个总体的了解,那我们接下来继续探究 ThreadLocal 的真实面貌以及使用。

3. ThreadLocal 的作用

作用:ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

ThreadLocal 是线程本地存储,在每个线程中都创建了一个 ThreadLocalMap 对象,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享。

使用场景:如为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B 线程正在使用的 Connection。还有 Session 管理等问题。

4. ThreadLocal set 方法

方法介绍:set 方法是为了设置 ThreadLocal 变量,设置成功后,该变量只能够被当前线程访问,其他线程不可直接访问操作改变量。

实例

public class DemoTest{
    public static void main(String[] args){
        ThreadLocal<String> localVariable = new ThreadLocal<> () ;
        localVariable.set("Hello World");
    }
}
代码块123456

Tips:set 方法可以设置任何类型的值,无论是 String 类型 ,Integer 类型,Object 等类型,原因在于 set 方法的 JDK 源码实现是基于泛型的实现,此处只是拿 String 类型进行的举例。

实例

public void set(T value) { // T value , 泛型实现,可以 set 任何对象类型
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
代码块12345678

5. ThreadLocal get 方法

方法介绍:get 方法是为了获取 ThreadLocal 变量的值,get 方法没有任何入参,直接调用即可获取。

实例

public class DemoTest{
    public static void main(String[] args){
        ThreadLocal<String> localVariable = new ThreadLocal<> () ;
        localVariable.set("Hello World");
        System.out.println(localVariable.get());
    }
}

结果验证

Hello World
代码块1

探究:请看如下程序,并给出输出结果

实例

public class DemoTest{
    public static void main(String[] args){
        ThreadLocal<String> localVariable = new ThreadLocal<> () ;
        localVariable.set("Hello World");
        localVariable.set("World is beautiful");
        System.out.println(localVariable.get());
        System.out.println(localVariable.get());
    }
}

探究解析:从程序中来看,我们进行了两次 set 方法的使用。

第一次 set 的值为 Hello World ;第二次 set 的值为 World is beautiful。接下来我们进行了两次打印输出 get 方法,那么这两次打印输出的结果都会是 World is beautiful。 原因在于第二次 set 的值覆盖了第一次 set 的值,所以只能 get 到 World is beautiful。

结果验证

World is beautiful
World is beautiful
代码块12

总结:ThreadLocal 中只能设置一个变量值,因为多次 set 变量的值会覆盖前一次 set 的值,我们之前提出过,ThreadLocal 其实是使用 ThreadLocalMap 进行的 value 存储,那么多次设置会覆盖之前的 value,这是 get 方法无需入参的原因,因为只有一个变量值。

6. ThreadLocal remove 方法

方法介绍:remove 方法是为了清除 ThreadLocal 变量,清除成功后,该 ThreadLocal 中没有变量值。

实例

public class DemoTest{
    public static void main(String[] args){
        ThreadLocal<String> localVariable = new ThreadLocal<> () ;
        localVariable.set("Hello World");
        System.out.println(localVariable.get());
        localVariable.remove();
        System.out.println(localVariable.get());
    }
}

Tips:remove 方法同 get 方法一样,是没有任何入参的,因为 ThreadLocal 中只能存储一个变量值,那么 remove 方法会直接清除这个变量值。

结果验证

Hello World
null
代码块12

7. 多线程下的 ThreadLocal

对 ThreadLocal 的常用方法我们已经进行了详细的讲解,那么多线程下的 ThreadLocal 才是它存在的真实意义,那么问了更好的学习多线程下的 ThreadLocal,我们来进行场景的创建,通过场景进行代码实验,更好的体会并掌握 ThreadLocal 的使用。

场景设计

  • 创建一个全局的静态 ThreadLocal 变量,存储 String 类型变量;

  • 创建两个线程,分别为 threadOne 和 threadTwo;

  • threadOne 进行 set 方法设置,设置完成后沉睡 5000 毫秒,苏醒后进行 get 方法打印;

  • threadTwo 进行 set 方法设置,设置完成后直接 get 方法打印,打印完成后调用 remove 方法,并打印 remove 方法调用完毕语句;

  • 开启线程 threadOne 和 threadTwo ;

  • 执行程序,并观察打印结果。

结果预期:在 threadOne 设置成功后进入了 5000 毫秒的休眠状态,此时由于只有 threadTwo 调用了 remove 方法,不会影响 threadOne 的 get 方法打印,这体现了 ThreadLocal 变量的最显著特性,线程私有操作。

实例

public class DemoTest{
    static ThreadLocal<String> local = new ThreadLocal<>();
    public static void main(String[] args){
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                local.set("threadOne's local value");
                try {
                    Thread.sleep(5000); //沉睡5000 毫秒,确保 threadTwo 执行 remove 完成
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(local.get());
            }
        });
        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                local.set("threadTwo's local value");
                System.out.println(local.get());
                local.remove();
                System.out.println("local 变量执行 remove 操作完毕。");
            }
        });
        threadTwo. start();
        threadOne. start();
    }
}

结果验证

threadTwo's local value
local 变量执行 remove 操作完毕。
threadOne's local value
代码块123

从以上结果来看,在 threadTwo 执行完 remove 方法后,threadOne 仍然能够成功打印,这更加证明了 ThreadLocal 的专属特性,线程独有数据,其他线程不可侵犯。

8. 小结

ThreadLocal 是解决线程安全问题一个很好的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal 比直接使用 synchronized 同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。

本节的重中之重是掌握 ThreadLocal 的方法使用以及其特点,核心内容为多线程下的 ThreadLocal 的使用。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多