zoukankan      html  css  js  c++  java
  • 并发编程学习笔记-1-(线程方法的基本操作与使用以及线程之间各种状态的转换,synchronized实现原理以及对象头,死锁,ReentrantLock基本使用)

    并发笔记参考自这位大佬https://gitee.com/gu_chun_bo与黑马培训结构笔记,感谢。

    1. 概览

    1.4 预备知识 希望你不是一个初学者 线程安全问题,需要你接触过 Java Web 开发、Jdbc 开发、Web 服务器、分布式框架时才会遇到 基于 JDK 8,最好对函数式编程、lambda 有一定了解 采用了 slf4j 打印日志,这是好的实践 采用了 lombok 简化 java bean 编写 给每个线程好名字,这也是一项好的实践

    前言:文中出现的示例代码地址为:gitee代码地址

    线程与进程

    2.1 进程与进程

    进程

    • 程序由指令和数据组成,但是这些指令要运行,数据要读写,就必须将指令加载到cpu,数据加载至内存。在指令运行过程中还需要用到磁盘,网络等设备,进程就是用来加载指令管理内存管理IO的
    • 当一个指令被运行,从磁盘加载这个程序的代码到内存,这时候就开启了一个进程
    • 进程就可以视为程序的一个实例,大部分程序都可以运行多个实例进程(例如记事本,浏览器等),部分只可以运行一个实例进程(例如360安全卫士)

    线程

    • 一个进程之内可以分为一到多个线程。
    • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
    • Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作 为线程的容器(这里感觉要学了计算机组成原理之后会更有感觉吧!)

    二者对比

    进程基本上相互独立的,而线程存在于进程内,是进程的一个子集 进程拥有共享的资源,如内存空间等,供其内部的线程共享 进程间通信较为复杂 同一台计算机的进程通信称为 IPC(Inter-process communication) 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

    2.2 并行与并发

    并发

    在单核 cpu 下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感 觉是同时运行的 。一般会将这种线程轮流使用 CPU 的做法称为并发(concurrent)

    1583408729416

    并行

    多核 cpu下,每个核(core) 都可以调度运行线程,这时候线程可以是并行的,不同的线程同时使用不同的cpu在执行。

    1583408812725

    二者对比

    引用 Rob Pike 的一段描述:并发(concurrent)是同一时间应对(dealing with)多件事情的能力,并行(parallel)是同一时间动手做(doing)多件事情的能力

    • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
    • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
    • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一 个人用锅时,另一个人就得等待)

    应用

    同步和异步的概念

    以调用方的角度讲,如果需要等待结果返回才能继续运行的话就是同步,如果不需要等待就是异步

    1) 设计

    多线程可以使方法的执行变成异步的,比如说读取磁盘文件时,假设读取操作花费了5秒,如果没有线程的调度机制,这么cpu只能等5秒,啥都不能做。

    2) 结论

    • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
    • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
    • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

    java线程

    3.1 创建和运行线程

    方法一,直接使用 Thread

    // 构造方法的参数是给线程指定名字,,推荐给线程起个名字
    Thread t1 = new Thread("t1") {
     @Override
     // run 方法内实现了要执行的任务
     public void run() {
     log.debug("hello");
     }
    };
    t1.start();
    

    方法二,使用 Runnable 配合 Thread

    把【线程】和【任务】(要执行的代码)分开,Thread 代表线程,Runnable 可运行的任务(线程要执行的代码)Test2.java

    // 创建任务对象
    Runnable task2 = new Runnable() {
     @Override
     public void run() {
     log.debug("hello");
     }
    };
    // 参数1 是任务对象; 参数2 是线程名字,推荐给线程起个名字
    Thread t2 = new Thread(task2, "t2");
    t2.start();
    

    小结

    方法1 是把线程和任务合并在了一起,方法2 是把线程和任务分开了,用 Runnable 更容易与线程池等高级 API 配合,用 Runnable 让任务类脱离了 Thread 继承体系,更灵活。通过查看源码可以发现,方法二其实到底还是通过方法一执行的!

    方法三,FutureTask 配合 Thread

    FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况 Test3.java

        public static void main(String[] args) throws ExecutionException, InterruptedException {
            // 实现多线程的第三种方法可以返回数据
            FutureTask futureTask = new FutureTask<>(new Callable<Integer>() {
                @Override
                public Integer call() throws Exception {
                    log.debug("多线程任务");
                    Thread.sleep(100);
                    return 100;
                }
            });
            // 主线程阻塞,同步等待 task 执行完毕的结果
            new Thread(futureTask,"我的名字").start();
            log.debug("主线程");
            log.debug("{}",futureTask.get());
        }
    

    Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。

    public interface Future<V> {
        boolean cancel(boolean mayInterruptIfRunning);
        boolean isCancelled();
        boolean isDone();
        V get() throws InterruptedException, ExecutionException;
        V get(long timeout, TimeUnit unit)
            throws InterruptedException, ExecutionException, TimeoutException;
    }
    
    

    Future提供了三种功能:   

    1. 判断任务是否完成;   

    2. 能够中断任务;   

    3. 能够获取任务执行结果。 

    3.2 线程运行原理

    虚拟机栈与栈帧

    拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,是属于线程的私有的。当java中使用多线程时,每个线程都会维护它自己的栈帧列表!每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

    线程上下文切换(Thread Context Switch)

    因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

    • 线程的 cpu 时间片用完(每个线程轮流执行,看前面并行的概念)
    • 垃圾回收
    • 有更高优先级的线程需要运行
    • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法

    当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念 就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的

    3.3 Thread的常见方法

    1583466371181

    3.3.1 start 与 run

    调用start

        public static void main(String[] args) {
            Thread thread = new Thread(){
              @Override
              public void run(){
                  log.debug("我是一个新建的线程正在运行中");
                  FileReader.read(fileName);
              }
            };
            thread.setName("新建线程");
            thread.start();
            log.debug("主线程");
        }
    

    输出:程序在 t1 线程运行, run()方法里面内容的调用是异步的 Test4.java

    11:59:40.711 [main] DEBUG com.concurrent.test.Test4 - 主线程
    11:59:40.711 [新建线程] DEBUG com.concurrent.test.Test4 - 我是一个新建的线程正在运行中
    11:59:40.732 [新建线程] DEBUG com.concurrent.test.FileReader - read [test] start ...
    11:59:40.735 [新建线程] DEBUG com.concurrent.test.FileReader - read [test] end ... cost: 3 ms
    

    调用run

    将上面代码的thread.start();改为 thread.run();输出结果如下:程序仍在 main 线程运行, run()方法里面内容的调用还是同步的

    12:03:46.711 [main] DEBUG com.concurrent.test.Test4 - 我是一个新建的线程正在运行中
    12:03:46.727 [main] DEBUG com.concurrent.test.FileReader - read [test] start ...
    12:03:46.729 [main] DEBUG com.concurrent.test.FileReader - read [test] end ... cost: 2 ms
    12:03:46.730 [main] DEBUG com.concurrent.test.Test4 - 主线程
    

    小结

    直接调用 run() 是在主线程中执行了 run(),没有启动新的线程 使用 start() 是启动新的线程,通过新的线程间接执行 run()方法 中的代码

    3.3.2 sleep 与 yield

    sleep

    1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
    2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,那么被打断的线程这时就会抛出 InterruptedException异常【注意:这里打断的是正在休眠的线程,而不是其它状态的线程】
    3. 睡眠结束后的线程未必会立刻得到执行(需要分配到cpu时间片)
    4. 建议用 TimeUnit 的 sleep() 代替 Thread 的 sleep()来获得更好的可读性

    yield

    1. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
    2. 具体的实现依赖于操作系统的任务调度器(就是可能没有其它的线程正在执行,虽然调用了yield方法,但是也没有用)

    小结

    yield使cpu调用其它线程,但是cpu可能会再分配时间片给该线程;而sleep需要等过了休眠时间之后才有可能被分配cpu时间片

    3.3.3 线程优先级

    线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用

    3.3.4 join

    在主线程中调用t1.join,则主线程会等待t1线程执行完之后再继续执行 Test10.java

        private static void test1() throws InterruptedException {
            log.debug("开始");
            Thread t1 = new Thread(() -> {
                log.debug("开始");
                sleep(1);
                log.debug("结束");
                r = 10;
            },"t1");
            t1.start();
            t1.join();
            log.debug("结果为:{}", r);
            log.debug("结束");
        }
    

    1583483843354

    有时效的 join

      等够时间

    static int r1 = 0;
    static int r2 = 0;
    
    public static void main(String[] args) throws InterruptedException {
        test3();
    }
    
    public static void test3() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            sleep(1);
            r1 = 10;
        });
        long start = System.currentTimeMillis();
        t1.start();
        // 线程执行结束会导致 join 结束
        t1.join(1500);
        long end = System.currentTimeMillis();
        log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
    }
    
    
    输出
    
    20:48:01.320 [main] c.TestJoin - r1: 10 r2: 0 cost: 1010
     
    没等够时间
    static int r1 = 0;
    static int r2 = 0;
    
    public static void main(String[] args) throws InterruptedException {
        test3();
    }
    
    public static void test3() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            sleep(2);
            r1 = 10;
        });
        long start = System.currentTimeMillis();
        t1.start();
        // 线程执行结束会导致 join 结束
        t1.join(1500);
        long end = System.currentTimeMillis();
        log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
    }
    
    输出
    
    20:52:15.623 [main] c.TestJoin - r1: 0 r2: 0 cost: 1502


    3.3.5 interrupt 方法详解

    打断 sleep,wait,join 的线程

    sleep,wait,join 的线程,这几个方法都会让线程进入阻塞状态,以 sleep 为例

    private static void test1() throws InterruptedException {
        Thread t1 = new Thread(()->{
            sleep(1);
        }, "t1");
        t1.start();
        sleep(0.5);
        t1.interrupt();
        log.debug(" 打断状态: {}", t1.isInterrupted());
    }
    输出
    
    java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at java.lang.Thread.sleep(Thread.java:340)
    at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
    at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
    at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
    at java.lang.Thread.run(Thread.java:745)
            21:18:10.374 [main] c.TestInterrupt - 打断状态: false

    打断正常运行的线程

    打断正常运行的线程, 线程并不会暂停,只是调用方法Thread.currentThread().isInterrupted();的返回值为true,可以判断Thread.currentThread().isInterrupted();的值来手动停止线程

        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(() -> {
                while(true) {
                    boolean interrupted = Thread.currentThread().isInterrupted();
                    if(interrupted) {
                        log.debug("被打断了, 退出循环");
                        break;
                    }
                }
            }, "t1");
            t1.start();
            Thread.sleep(1000);
            log.debug("interrupt");
            t1.interrupt();
        }
    

    终止模式之两阶段终止模式

    Two Phase Termination,就是考虑在一个线程T1中如何优雅地终止另一个线程T2?这里的优雅指的是给T2一个料理后事的机会(如释放锁)。

    如下所示:那么线程的isInterrupted()方法可以取得线程的打断标记,如果线程在睡眠sleep期间被打断,打断标记是不会变的,为false,但是sleep期间被打断会抛出异常,我们据此手动设置打断标记为true;如果是在程序正常运行期间被打断的,那么打断标记就被自动设置为true。处理好这两种情况那我们就可以放心地来料理后事啦!

    代码实现如下:

    @Slf4j
    public class Test11 {
        public static void main(String[] args) throws InterruptedException {
            TwoParseTermination twoParseTermination = new TwoParseTermination();
            twoParseTermination.start();
            Thread.sleep(3000);  // 让监控线程执行一会儿
            twoParseTermination.stop(); // 停止监控线程
        }
    }
    
    
    @Slf4j
    class TwoParseTermination{
        Thread thread ;
        public void start(){
            thread = new Thread(()->{
                while(true){
                    if (Thread.currentThread().isInterrupted()){
                        log.debug("线程结束。。正在料理后事中");
                        break;
                    }
                    try {
                        Thread.sleep(500);
                        log.debug("正在执行监控的功能");
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        e.printStackTrace();
                    }
                }
            });
            thread.start();
        }
        public void stop(){
            thread.interrupt();
        }
    }
    
    打断 park 线程
    打断 park 线程, 不会清空打断状态
    
    private static void test3() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            log.debug("park...");
            LockSupport.park();
            log.debug("unpark...");
            log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
        }, "t1");
        t1.start();
        sleep(0.5);
        t1.interrupt();
    }
    输出
    
    21:11:52.795 [t1] c.TestInterrupt - park...
    21:11:53.295 [t1] c.TestInterrupt - unpark...
    21:11:53.295 [t1] c.TestInterrupt - 打断状态:true
    
    如果打断标记已经是 true, 则 park 会失效
    
    private static void test4() {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                log.debug("park...");
                LockSupport.park();
                log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
            }
        });
        t1.start();
        sleep(1);
        t1.interrupt();
    }
    输出
    
    21:13:48.783[Thread-0]c.TestInterrupt -park...
    21:13:49.809[Thread-0]c.TestInterrupt -打断状态:true
    21:13:49.812[Thread-0]c.TestInterrupt -park...
    21:13:49.813[Thread-0]c.TestInterrupt -打断状态:true
    21:13:49.813[Thread-0]c.TestInterrupt -park...
    21:13:49.813[Thread-0]c.TestInterrupt -打断状态:true
    21:13:49.813[Thread-0]c.TestInterrupt -park...
    21:13:49.813[Thread-0]c.TestInterrupt -打断状态:true
    21:13:49.813[Thread-0]c.TestInterrupt -park...
    21:13:49.813[Thread-0]c.TestInterrupt -打断状态:true
    可以使用 Thread.interrupted() 清除打断状态

     

    3.3.6 sleep,yiled,wait,join 对比

    补充:

    1. sleep,join,yield,interrupted是Thread类中的方法
    2. wait/notify是object中的方法

    sleep 不释放锁、释放cpu join 释放锁、抢占cpu yiled 不释放锁、释放cpu wait 释放锁、释放cpu

    3.4 守护线程

    默认情况下,java进程需要等待所有的线程结束后才会停止,但是有一种特殊的线程,叫做守护线程,在其他线程全部结束的时候即使守护线程还未结束代码未执行完java进程也会停止。普通线程t1可以调用t1.setDeamon(true); 方法变成守护线程

    注意 垃圾回收器线程就是一种守护线程 Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等 待它们处理完当前请求

    3.5 线程状态之五种状态

    五种状态的划分主要是从操作系统的层面进行划分的

    1583507073055

    1. 初始状态,仅仅是在语言层面上创建了线程对象,即Thead thread = new Thead();,还未与操作系统线程关联
    2. 可运行状态,也称就绪状态,指该线程已经被创建,与操作系统相关联,等待cpu给它分配时间片就可运行
    3. 运行状态,指线程获取了CPU时间片,正在运行
      1. 当CPU时间片用完,线程会转换至【可运行状态】,等待 CPU再次分配时间片,会导致我们前面讲到的上下文切换
    4. 阻塞状态
      1. 如果调用了阻塞API,如BIO读写文件,那么线程实际上不会用到CPU,不会分配CPU时间片,会导致上下文切换,进入【阻塞状态】
      2. 等待BIO操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
      3. 与【可运行状态】的区别是,只要操作系统一直不唤醒线程,调度器就一直不会考虑调度它们,CPU就一直不会分配时间片
    5. 终止状态,表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

    3.6 线程状态之六种状态

    这是从 Java API 层面来描述的,我们主要研究的就是这种。状态转换详情图:地址 根据 Thread.State 枚举,分为六种状态 Test12.java

    1583507709834

    1. NEW 跟五种状态里的初始状态是一个意思
    2. RUNNABLE 是当调用了 start() 方法之后的状态,注意,Java API 层面的 RUNNABLE 状态涵盖了操作系统层面的【可运行状态】、【运行状态】和【io阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
    3. BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节 详述

    线程问题

    4.1 线程出现问题的根本原因分析

    线程出现问题的根本原因是因为线程上下文切换,导致线程里的指令没有执行完就切换执行其它线程了,下面举一个例子 Test13.java

        static int count = 0;
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(()->{
                for (int i = 1;i<5000;i++){
                    count++;
                }
            });
            Thread t2 =new Thread(()->{
                for (int i = 1;i<5000;i++){
                    count--;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            log.debug("count的值是{}",count);
        }
    

    我将从字节码的层面进行分析:

    1583568350082

    1583568587168

    getstatic i // 获取静态变量i的值
    iconst_1 // 准备常量1
    iadd // 自增
    putstatic i // 将修改后的值存入静态变量i
        
    getstatic i // 获取静态变量i的值
    iconst_1 // 准备常量1
    isub // 自减
    putstatic i // 将修改后的值存入静态变量i
    

    可以看到count++ 和 count-- 操作实际都是需要这个4个指令完成的,那么这里问题就来了!Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

    1583569253392

    如果代码是正常按顺序运行的,那么count的值不会计算错

    1583569326977

    出现负数的情况:

    1583569380639

    出现正数的情况:

    1583569416016

    问题的进一步描述

    临界区

    1. 一个程序运行多线程本身是没有问题的

    2. 问题出现在多个线程共享资源的时候

      1. 多个线程同时对共享资源进行读操作本身也没有问题
      2. 问题出现在对对共享资源同时进行读写操作时就有问题了 
    3. 先定义一个叫做临界区的概念:一段代码内如果存在对共享资源的多线程读写操作,那么称这段代码为临界区

      1. static int counter = 0;
        static void increment()
        {// 临界区
         counter++;
        }
        static void decrement()
        {// 临界区
         counter--;
        }
        

    竞态条件

    多个线程在临界区执行,那么由于代码指令的执行不确定而导致的结果问题,称为竞态条件

    4.2 synchronized 解决方案

    为了避免临界区中的竞态条件发生,由多种手段可以达到

    • 阻塞式解决方案:synchronized ,Lock
    • 非阻塞式解决方案:原子变量

    现在讨论使用synchronized来进行解决,即俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程持有对象锁,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换

    注意 虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的: 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区的代码 同步是由于线程执行的先后,顺序不同但是需要一个线程等待其它线程运行到某个点。

    synchronized

    synchronized(对象) // 线程1获得锁, 那么线程2的状态是(blocked)
    {
     临界区
    }
    

    上面的实例程序使用synchronized后如下,计算出的结果是正确!Test13.java

    static int counter = 0;
    static final Object room = new Object();
    public static void main(String[] args) throws InterruptedException {
         Thread t1 = new Thread(() -> {
             for (int i = 0; i < 5000; i++) {
                 synchronized (room) {
                 counter++;
            	}
     		}
     	}, "t1");
         Thread t2 = new Thread(() -> {
             for (int i = 0; i < 5000; i++) {
                 synchronized (room) {
                 counter--;
             }
         }
         }, "t2");
         t1.start();
         t2.start();
         t1.join();
         t2.join();
         log.debug("{}",counter);
    }
    
    

    synchronized原理

    synchronized实际上利用对象保证了临界区代码的原子性,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断

    1583571633729

    synchronized 加在方法上

        class Test{
            public synchronized void test() {
    
            }
        }
        //等价于
        class Test{
            public void test() {
                synchronized(this) {
    
                }
            }
        }
    //------------------------------------------------------------------------------------------------
        class Test{
            public synchronized static void test() {
            }
        }
       // 等价于
        class Test{
            public static void test() {
                synchronized(Test.class) {
    
                }
            }
        }
    
    

    4.3 变量的线程安全分析

    4.3.1 成员变量和静态变量的线程安全分析

    • 如果没有变量没有在线程间共享,那么变量是安全的
    • 如果变量在线程间共享
      • 如果只有读操作,则线程安全
      • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

    4.3.2 局部变量线程安全分析

    • 局部变量【局部变量被初始化为基本数据类型】是安全的
    • 局部变量引用的对象未必是安全的
      • 如果局部变量引用的对象没有引用线程共享的对象,那么是线程安全的
      • 如果局部变量引用的对象引用了一个线程共享的对象,那么要考虑线程安全的

    线程安全的情况

    局部变量【局部变量被初始化为基本数据类型】是安全的,示例如下

    public static void test1() {
         int i = 10;
         i++;
    }
    

    每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享

    1583587166210

    线程不安全的情况

    如果局部变量引用的对象逃离方法的范围,那么要考虑线程安全的,代码示例如下 Test15.java

    public class Test15 {
        public static void main(String[] args) {
            UnsafeTest unsafeTest = new UnsafeTest();
            for (int i =0;i<100;i++){
                new Thread(()->{
                    unsafeTest.method1();
                },"线程"+i).start();
            }
        }
    }
    class UnsafeTest{
        ArrayList<String> arrayList = new ArrayList<>();
        public void method1(){
            for (int i = 0; i < 100; i++) {
                method2();
                method3();
            }
        }
        private void method2() {
            arrayList.add("1");
        }
        private void method3() {
            arrayList.remove(0);
        }
    }
    
    不安全原因分析

    无论哪个线程中的 method2 和method3 引用的都是同一个对象中的 list 成员变量:一个 ArrayList ,在添加一个元素的时候,它可能会有两步来完成:

    1. 第一步,在 arrayList[Size] 的位置存放此元素; 第二步增大 Size 的值。 
    2. 在单线程运行的情况下,如果 Size = 0,添加一个元素后,此元素在位置 0,而且 Size=1;而如果是在多线程情下,比如有两个线程,线程 A 先将元素存放在位置 0。但是此时 CPU 调线程A暂停,线程 B 得到运行的机会。线程B也向此 ArrayList 添加元素,因为此时 Size 仍等于 0 (注意哦,我们假设的是添加一个元素是要两个步骤哦,而线程A仅仅完成了步骤1),所以线程B也将元素存放在位置0。然后线程A和线程B都继续运行,都增加 Size 的值。 那好,现在我们来看看 ArrayList 的情况,元素实际上只有一个,存放在位置 0,而 Size 却等于 2。这就是“线程不 安全”了。 

    1583589268096

    1583587571334

    解决方法

    可以将list修改成局部变量,那么就不会有上述问题了

    class safeTest{
        public void method1(){
            ArrayList<String> arrayList = new ArrayList<>();
            for (int i = 0; i < 100; i++) {
            method2(arrayList);
            method3(arrayList);}
        }
        private void method2(ArrayList arrayList) {
            arrayList.add("1");
        }
        private void method3(ArrayList arrayList) {
            arrayList.remove(0);
        }
    }
    
    思考 private 或 final的重要性

    方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会导致线程安全问题?情况1:有其它线程调用 method2 和 method3;情况2:在情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即如下所示: 从这个例子可以看出 private 或 final 提供【安全】的意义所在,请体会开闭原则中的【闭】

    class ThreadSafe {
        public final void method1(int loopNumber) {
            ArrayList<String> list = new ArrayList<>();
            for (int i = 0; i < loopNumber; i++) {
                method2(list);
                method3(list);
            }
        }
        private void method2(ArrayList<String> list) {
            list.add("1");
        }
        private void method3(ArrayList<String> list) {
            list.remove(0);
        }
    }
    class ThreadSafeSubClass extends ThreadSafe{
        @Override
        public void method3(ArrayList<String> list) {
            new Thread(() -> {
                list.remove(0);
            }).start();
        }
    }
    

    4.3.3 常见线程安全类

    1. String
    2. Integer
    3. StringBuffer
    4. Random
    5. Vector
    6. Hashtable
    7. java.util.concurrent 包下的类

    这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的

    Hashtable table = new Hashtable();
    new Thread(()->{
     	table.put("key", "value1");
    }).start();
    new Thread(()->{
     	table.put("key", "value2");
    }).start();
    
    

    线程安全类方法的组合

    但注意它们多个方法的组合不是原子的,见下面分析

    Hashtable table = new Hashtable();
    // 线程1,线程2
    if( table.get("key") == null) {
     table.put("key", value);
    }
    

    1583590979975

    不可变类的线程安全

    StringInteger类都是不可变的类,因为其类内部状态是不可改变的,因此它们的方法都是线程安全的,有同学或许有疑问,String 有 replacesubstring 等方法【可以】改变值啊,其实调用这些方法返回的已经是一个新创建的对象了!

    public class Immutable{
         private int value = 0;
         public Immutable(int value){
         this.value = value;
     	}
         public int getValue(){
             return this.value;
         }
         public Immutable add(int v){
             return new Immutable(this.value + v);
         }
    }
    

    示例分析-是否线程安全

    示例一

    分析线程是否安全,先对类的成员变量,类变量,局部变量进行考虑,如果变量会在各个线程之间共享,那么就得考虑线程安全问题了,如果变量A引用的是线程安全类的实例,并且只调用该线程安全类的一个方法,那么该变量A是线程安全的的。下面对实例一进行分析:此类不是线程安全的,MyAspect切面类只有一个实例,成员变量start 会被多个线程同时进行读写操作

    @Aspect
    @Component
    public class MyAspect {
            // 是否安全?
            private long start = 0L;
    
            @Before("execution(* *(..))")
            public void before() {
                start = System.nanoTime();
            }
    
            @After("execution(* *(..))")
            public void after() {
                long end = System.nanoTime();
                System.out.println("cost time:" + (end-start));
            }
        }
    
    示例二

    此例是典型的三层模型调用,MyServlet UserServiceImpl UserDaoImpl类都只有一个实例,UserDaoImpl类中没有成员变量,update方法里的变量引用的对象不是线程共享的,所以是线程安全的;UserServiceImpl类中只有一个线程安全的UserDaoImpl类的实例,那么UserServiceImpl类也是线程安全的,同理 MyServlet也是线程安全的

    public class MyServlet extends HttpServlet {
     // 是否安全
     private UserService userService = new UserServiceImpl();
    
     public void doGet(HttpServletRequest request, HttpServletResponse response) {
     userService.update(...);
     }
    }
    public class UserServiceImpl implements UserService {
     // 是否安全
     private UserDao userDao = new UserDaoImpl();
     public void update() {
     userDao.update();
     }
    }
    public class UserDaoImpl implements UserDao {
     public void update() {
     String sql = "update user set password = ? where username = ?";
     // 是否安全
     try (Connection conn = DriverManager.getConnection("","","")){
     // ...
     } catch (Exception e) {
     // ...
     }
     }
    }
    
    示例三

    跟示例二大体相似,UserDaoImpl类中有成员变量,那么多个线程可以对成员变量conn 同时进行操作,故是不安全的

    public class MyServlet extends HttpServlet {
        // 是否安全
        private UserService userService = new UserServiceImpl();
    
        public void doGet(HttpServletRequest request, HttpServletResponse response) {
            userService.update(...);
        }
    }
    public class UserServiceImpl implements UserService {
        // 是否安全
        private UserDao userDao = new UserDaoImpl();
        public void update() {
            userDao.update();
        }
    }
    public class UserDaoImpl implements UserDao {
        // 是否安全
        private Connection conn = null;
        public void update() throws SQLException {
            String sql = "update user set password = ? where username = ?";
            conn = DriverManager.getConnection("","","");
            // ...
            conn.close();
        }
    }
    
    示例四

    跟示例三大体相似,UserServiceImpl类的update方法中 UserDao是作为局部变量存在的,所以每个线程访问的时候都会新建有一个UserDao对象,新建的对象是线程独有的,所以是线程安全的

    public class MyServlet extends HttpServlet {
        // 是否安全
        private UserService userService = new UserServiceImpl();
        public void doGet(HttpServletRequest request, HttpServletResponse response) {
            userService.update(...);
        }
    }
    public class UserServiceImpl implements UserService {
        public void update() {
            UserDao userDao = new UserDaoImpl();
            userDao.update();
        }
    }
    public class UserDaoImpl implements UserDao {
        // 是否安全
        private Connection = null;
        public void update() throws SQLException {
            String sql = "update user set password = ? where username = ?";
            conn = DriverManager.getConnection("","","");
            // ...
            conn.close();
        }
    }
    
    示例五
    public abstract class Test {
        public void bar() {
            // 是否安全
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            foo(sdf);
        }
        public abstract foo(SimpleDateFormat sdf);
        public static void main(String[] args) {
            new Test().bar();
        }
    }
    

    其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法,因为foo方法可以被重写,导致线程不安全。在String类中就考虑到了这一点,String类是finally的,子类不能重写它的方法。

        public void foo(SimpleDateFormat sdf) {
            String dateStr = "1999-10-11 00:00:00";
            for (int i = 0; i < 20; i++) {
                new Thread(() -> {
                    try {
                        sdf.parse(dateStr);
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }).start();
            }
        }
    

    4.4 习题分析

    Test16.java

    4.5 Monitor 概念

    Java 对象头

    以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;

    1583651065372

    数组对象

    1583651088663

    其中 Mark Word 结构为

    1583651590160

    所以一个对象的结构如下:

    1583678624634

    Monitor 原理

    Monitor被翻译为监视器或者说管程

    每个java对象都可以关联一个Monitor,如果使用synchronized给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针

    1583652360228

    • 刚开始时Monitor中的Owner为null
    • 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
    • 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
    • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
    • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析

    注意:synchronized 必须是进入同一个对象的 monitor 才有上述的效果不加 synchronized 的对象不会关联监视器,不遵从以上规则

    synchronized原理

    代码如下 Test17.java

        static final Object lock=new Object();
        static int counter = 0;
        public static void main(String[] args) {
            synchronized (lock) {
                counter++;
            }
        }
    

    反编译后的部分字节码

     0 getstatic #2 <com/concurrent/test/Test17.lock>
     # 取得lock的引用(synchronized开始了)
     3 dup    
     # 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
     4 astore_1
     # 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
     5 monitorenter
     # 将lock对象的Mark Word置为指向Monitor指针
     6 getstatic #3 <com/concurrent/test/Test17.counter>
     9 iconst_1
    10 iadd
    11 putstatic #3 <com/concurrent/test/Test17.counter>
    14 aload_1
    # 从局部变量表中取得lock的引用,放入操作数栈栈顶
    15 monitorexit
    # 将lock对象的Mark Word重置,唤醒EntryList
    16 goto 24 (+8)
    # 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
    19 astore_2
    20 aload_1
    21 monitorexit
    22 aload_2
    23 athrow
    24 return
    
    

    注意:方法级别的 synchronized 不会在字节码指令中有所体现

    synchronized 原理进阶

    轻量级锁

    轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是synchronized,假设有两个方法同步块,利用同一个对象加锁

    static final Object obj = new Object();
    public static void method1() {
         synchronized( obj ) {
             // 同步块 A
             method2();
         }
    }
    public static void method2() {
         synchronized( obj ) {
             // 同步块 B
         }
    }
    
    1. 每次指向到synchronized代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference
      1. 1583755737580
    2. 让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word ,将Mark Word 的值存入锁记录中
      1. 1583755888236
    3. 如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态01,如下所示
      1. 1583755964276
    4. 如果cas失败,有两种情况
      1. 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
      2. 如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条 Lock Record 作为重入的计数
        1. 1583756190177
    5. 当线程退出synchronized代码块的时候,**如果获取的是取值为 null 的锁记录 **,表示有重入,这时重置锁记录,表示重入计数减一
      1. 1583756357835
    6. 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象
      1. 成功则解锁成功
      2. 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

    锁膨胀

    如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。

    1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
      1. 1583757433691
    2. 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
      1. 即为对象申请Monitor锁,让Object指向重量级锁地址,然后自己进入Monitor 的EntryList 变成BLOCKED状态
      2. 1583757586447
    3. 当Thread-0 推出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程

    自旋优化(轻量锁向重量级锁转换的过程中会出现自旋这种情况)

    重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁

    1. 自旋重试成功的情况
      1. 1583758113724
    2. 自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
      1. 1583758136650

    自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能

    偏向锁

    在轻量级的锁中,我们可以发现,如果同一个线程对同一个2对象进行重入锁时,也需要执行CAS操作,这是有点耗时滴,那么java6开始引入了偏向锁的东东,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了

    1583760728806

    偏向状态(比较好的测试案例:https://blog.csdn.net/Angry_Mills/article/details/106724304)

    1583762169169

    一个对象的创建过程

    1. 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值.

    2. 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟,也可以通过sleep(5000)睡眠5s测试操作,注意必须在锁定的对象之前睡眠,对象之后睡眠是无效的。

    3. 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中

    4. 实验Test18.java,加上虚拟机参数-XX:BiasedLockingStartupDelay=0进行测试

      1. public static void main(String[] args) throws InterruptedException {
                Test1 t = new Test1();
                test.parseObjectHeader(getObjectHeader(t));
                synchronized (t){
                    test.parseObjectHeader(getObjectHeader(t));
                }
                test.parseObjectHeader(getObjectHeader(t));
            }
        
        1. 输出结果如下,三次输出的状态码都为101
        biasedLockFlag (1bit): 1
        	LockFlag (2bit): 01
        biasedLockFlag (1bit): 1
        	LockFlag (2bit): 01
        biasedLockFlag (1bit): 1
        	LockFlag (2bit): 01
        

    测试禁用:如果没有开启偏向锁,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode时才赋值的。在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized状态变回001

    1. 测试代码Test18.java 虚拟机参数-XX:-UseBiasedLocking

    2. 输出结果如下,最开始状态为001,然后加轻量级锁变成00,最后恢复成001

      biasedLockFlag (1bit): 0
      	LockFlag (2bit): 01
      LockFlag (2bit): 00
      biasedLockFlag (1bit): 0
      	LockFlag (2bit): 01
      
    撤销偏向锁-hashcode方法

    测试 hashCode:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode的值了

    1. 测试代码如下,使用虚拟机参数-XX:BiasedLockingStartupDelay=0 ,确保我们的程序最开始使用了偏向锁!但是结果显示程序还是使用了轻量级锁。 Test20.java

          public static void main(String[] args) throws InterruptedException {
              Test1 t = new Test1();
              t.hashCode();
              test.parseObjectHeader(getObjectHeader(t));
      
              synchronized (t){
                  test.parseObjectHeader(getObjectHeader(t));
              }
              test.parseObjectHeader(getObjectHeader(t));
          }
      
    2. 输出结果

      biasedLockFlag (1bit): 0
      	LockFlag (2bit): 01
      LockFlag (2bit): 00
      biasedLockFlag (1bit): 0
      	LockFlag (2bit): 01
      
    撤销偏向锁-其它线程使用对象

    这里我们演示的是偏向锁撤销变成轻量级锁的过程,那么就得满足轻量级锁的使用条件,就是没有线程对同一个对象进行锁竞争,我们使用wait 和 notify 来辅助实现

    1. 代码 Test19.java,虚拟机参数-XX:BiasedLockingStartupDelay=0确保我们的程序最开始使用了偏向锁!

    2. 输出结果,最开始使用的是偏向锁,但是第二个线程尝试获取对象锁时,发现本来对象偏向的是线程一,那么偏向锁就会失效,加的就是轻量级锁

      biasedLockFlag (1bit): 1
      	LockFlag (2bit): 01
      biasedLockFlag (1bit): 1
      	LockFlag (2bit): 01
      biasedLockFlag (1bit): 1
      	LockFlag (2bit): 01
      biasedLockFlag (1bit): 1
      	LockFlag (2bit): 01
      LockFlag (2bit): 00
      biasedLockFlag (1bit): 0
      	LockFlag (2bit): 01
      
    撤销 - 调用 wait/notify

    会使对象的锁变成重量级锁,因为wait/notify方法之后重量级锁才支持

    批量重偏向

    如果对象被多个线程访问,但是没有竞争,这时候偏向了线程一的对象又有机会重新偏向线程二,即可以不用升级为轻量级锁,可这和我们之前做的实验矛盾了呀,其实要实现重新偏向是要有条件的:就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。

    4.6 wait和notify

    建议先看看wait和notify方法的javadoc文档

    4.6.1同步模式之保护性暂停

    即 Guarded Suspension,用在一个线程等待另一个线程的执行结果,要点:

    1. 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
    2. 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
    3. JDK 中,join 的实现、Future 的实现,采用的就是此模式
    4. 因为要等待另一方的结果,因此归类到同步模式(可以理解为操作系统中的同步操作)

    代码:Test22.java Test23.java这是带超时时间的

    1594473284105

    Test23.java中jiang'dao'de关于超时的增强,在join(long millis) 的源码中得到了体现:

        public final synchronized void join(long millis)
        throws InterruptedException {
            long base = System.currentTimeMillis();
            long now = 0;
    
            if (millis < 0) {
                throw new IllegalArgumentException("timeout value is negative");
            }
    		
            if (millis == 0) {
                while (isAlive()) {
                    wait(0);
                }
            } else {
            // join一个指定的时间
                while (isAlive()) {
                    long delay = millis - now;
                    if (delay <= 0) {
                        break;
                    }
                    wait(delay);
                    now = System.currentTimeMillis() - base;
                }
            }
        }
    

    多任务版 GuardedObject图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。和生产者消费者模式的区别就是:这个生产者和消费者之间是一一对应的关系,但是生产者消费者模式并不是。rpc框架的调用中就使用到了这种模式。 Test24.java

    1594518049426

    新增 id 用来标识 Guarded Object
    class GuardedObject {
        // 标识 Guarded Object
        private int id;
    
        public GuardedObject(int id) {
            this.id = id;
        }
    
        public int getId() {
            return id;
        }
    
        // 结果
        private Object response;
    
        // 获取结果
        // timeout 表示要等待多久 2000
        public Object get(long timeout) {
            synchronized (this) {
                // 开始时间 15:00:00
                long begin = System.currentTimeMillis();
                // 经历的时间
                long passedTime = 0;
                while (response == null) {
                    // 这一轮循环应该等待的时间
                    long waitTime = timeout - passedTime;
                    // 经历的时间超过了最大等待时间时,退出循环
                    if (timeout - passedTime <= 0) {
                        break;
                    }
                    try {
                        this.wait(waitTime); // 虚假唤醒 15:00:01
                    } catch (InterruptedException e) {
                        e.printStackTrace();
    
                    }
                    // 求得经历时间
                    passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
                }
                return response;
            }
        }
    
        // 产生结果
        public void complete(Object response) {
            synchronized (this) {
                // 给结果成员变量赋值
                this.response = response;
                this.notifyAll();
            }
        }
    }
    
    中间解耦类
    
    class Mailboxes {
        private static Map<Integer, GuardedObject> boxes = new Hashtable<>();
        private static int id = 1;
    
        // 产生唯一 id
        private static synchronized int generateId() {
            return id++;
        }
    
        public static GuardedObject getGuardedObject(int id) {
            return boxes.remove(id);
        }
    
        public static GuardedObject createGuardedObject() {
            GuardedObject go = new GuardedObject(generateId());
            boxes.put(go.getId(), go);
            return go;
        }
    
        public static Set<Integer> getIds() {
            return boxes.keySet();
        }
    }
    
    业务相关类
    
    class People extends Thread {
        @Override
        public void run() {
            // 收信
            GuardedObject guardedObject = Mailboxes.createGuardedObject();
            log.debug("开始收信 id:{}", guardedObject.getId());
            Object mail = guardedObject.get(5000);
            log.debug("收到信 id:{}, 内容:{}", guardedObject.getId(), mail);
        }
    }
    
    class Postman extends Thread {
        private int id;
        private String mail;
    
        public Postman(int id, String mail) {
            this.id = id;
            this.mail = mail;
        }
    
        @Override
        public void run() {
            GuardedObject guardedObject = Mailboxes.getGuardedObject(id);
            log.debug("送信 id:{}, 内容:{}", id, mail);
            guardedObject.complete(mail);
        }
    }
    
    测试
    
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 3; i++) {
            new People().start();
        }
        Sleeper.sleep(1);
        for (Integer id : Mailboxes.getIds()) {
            new Postman(id, "内容" + id).start();
        }
    }
    
    某次运行结果
    10:35:05.689 c.People [Thread-1] - 开始收信 id:3
    10:35:05.689 c.People [Thread-2] - 开始收信 id:1
    10:35:05.689 c.People [Thread-0] - 开始收信 id:2
    10:35:06.688 c.Postman [Thread-4] - 送信 id:2, 内容:内容2
    10:35:06.688 c.Postman [Thread-5] - 送信 id:1, 内容:内容1
    10:35:06.688 c.People [Thread-0] - 收到信 id:2, 内容:内容2
    10:35:06.688 c.People [Thread-2] - 收到信 id:1, 内容:内容1
    10:35:06.688 c.Postman [Thread-3] - 送信 id:3, 内容:内容3
    10:35:06.689 c.People [Thread-1] - 收到信 id:3, 内容:内容3

    4.6.2同步模式之 Balking

    1. 定义 Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回

    4.6.3.同步模式之顺序控制

    1. 固定运行顺序 比如,必须先 2 后 1 打印

    4.6.4异步模式之生产者/消费者

    要点

    1. 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
    2. 消费队列可以用来平衡生产和消费的线程资源
    3. 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
    4. 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
    5. JDK 中各种阻塞队列,采用的就是这种模式

    “异步”的意思就是生产者产生消息之后消息没有被立刻消费,而“同步模式”中,消息在产生之后被立刻消费了。

    1594524622020

    我们写一个线程间通信的消息队列,要注意区别,像rabbit mq等消息框架是进程间通信的。

    4.7 park & unpack

    4.7.1 基本使用

    它们是 LockSupport 类中的方法 Test26.java

    // 暂停当前线程
    LockSupport.park();
    // 恢复某个线程的运行
    LockSupport.unpark;
    

    4.7.2 park unpark 原理

    每个线程都有自己的一个 Parker 对象,由三部分组成 _counter, _cond和 _mutex

    1. 打个比喻线程就像一个旅人,Parker 就像他随身携带的背包,条件变量 _ cond就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
    2. 调用 park 就是要看需不需要停下来歇息
      1. 如果备用干粮耗尽,那么钻进帐篷歇息
      2. 如果备用干粮充足,那么不需停留,继续前进
    3. 调用 unpark,就好比令干粮充足
      1. 如果这时线程还在帐篷,就唤醒让他继续前进
      2. 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
        1. 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮

    可以不看例子,直接看实现过程

    先调用park再调用upark的过程

    1.先调用park

    1. 当前线程调用 Unsafe.park() 方法
    2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁(mutex对象有个等待队列 _cond)
    3. 线程进入 _cond 条件变量阻塞
    4. 设置 _counter = 0

    1594531894163

    2.调用upark

    1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
    2. 唤醒 _cond 条件变量中的 Thread_0
    3. Thread_0 恢复运行
    4. 设置 _counter 为 0

    1594532057205

    先调用upark再调用park的过程

    1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
    2. 当前线程调用 Unsafe.park() 方法
    3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
    4. 设置 _counter 为 0

    1594532135616

    4.8 线程状态转换

    img

    1. RUNNABLE <--> WAITING

      1. 线程用synchronized(obj)获取了对象锁后
        1. 调用obj.wait()方法时,t 线程从RUNNABLE --> WAITING
        2. 调用obj.notify(),obj.notifyAll(),t.interrupt()时
          1. 竞争锁成功,t 线程从WAITING --> RUNNABLE
          2. 竞争锁失败,t 线程从WAITING --> BLOCKED
      2. Test27.java
    2. RUNNABLE <--> WAITING

      1. 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
      2. 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE
    3. RUNNABLE <--> WAITING

      1. 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING 注意是当前线程在t 线程对象的监视器上等待
      2. t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
    4. RUNNABLE <--> TIMED_WAITING

      t 线程用 synchronized(obj) 获取了对象锁后

      1. 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
      2. t 线程等待时间超过了 n 毫秒,或调用 obj.notify() , obj.notifyAll() , t.interrupt() 时
        1. 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
        2. 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
    5. RUNNABLE <--> TIMED_WAITING

      1. 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING 注意是当前线程在t 线程对象的监视器上等待
      2. 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING --> RUNNABLE
    6. RUNNABLE <--> TIMED_WAITING

      1. 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
      2. 当前线程等待时间超过了 n 毫秒或调用了线程 的 interrupt() ,当前线程从 TIMED_WAITING --> RUNNABLE
    7. RUNNABLE <--> TIMED_WAITING

      1. 当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,当前线 程从 RUNNABLE --> TIMED_WAITING
      2. 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE

    4.9 活跃性

    活跃性相关的一系列问题都可以用ReentrantLock进行解决。

    4.9.1 死锁

    有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁t1 线程获得A对象锁,接下来想获取B对象的锁;t2 线程获得B对象锁,接下来想获取A对象的锁例。Test28.java

    4.9.2 定位死锁

    检测死锁可以使用 jconsole工具;或者使用 jps 定位进程 id,再用 jstack 定位死锁:Test28.java

    下面使用jstack工具进行演示

    第一步:
    cmd > jps
    Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
    12320 Jps
    22816 KotlinCompileDaemon
    33200 TestDeadLock // JVM 进程
    11508 Main
    28468 Launcher
    第二步:
    cmd > jstack 33200
    // 略去部分输出
    Found one Java-level deadlock:
    =============================
    "Thread-1":
    waiting to lock monitor 0x000000000361d378 (object 0x000000076b5bf1c0, a java.lang.Object),
    which is held by "Thread-0"
    "Thread-0":
    waiting to lock monitor 0x000000000361e768 (object 0x000000076b5bf1d0, a java.lang.Object),
    which is held by "Thread-1"
    Java stack information for the threads listed above:
    ===================================================
    "Thread-1":
    at thread.TestDeadLock.lambda$main$1(TestDeadLock.java:28)
    - waiting to lock <0x000000076b5bf1c0> (a java.lang.Object)
    - locked <0x000000076b5bf1d0> (a java.lang.Object)
    at thread.TestDeadLock$$Lambda$2/883049899.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)
    "Thread-0":
    at thread.TestDeadLock.lambda$main$0(TestDeadLock.java:15)
    - waiting to lock <0x000000076b5bf1d0> (a java.lang.Object)
    - locked <0x000000076b5bf1c0> (a java.lang.Object)
    at thread.TestDeadLock$$Lambda$1/495053715.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)
    Found 1 deadlock.

    4.9.3 哲学家就餐问题

    1594553609905

    有五位哲学家,围坐在圆桌旁。 他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。 吃饭时要用两根筷子吃,桌上共有 5 根筷子,每位哲学家左右手边各有一根筷子。 如果筷子被身边的人拿着,自己就得等待 Test29.java

    当每个哲学家即线程持有一根筷子时,他们都在等待另一个线程释放锁,因此造成了死锁。这种线程没有按预期结束,执行不下去的情况,归类为【活跃性】问题,除了死锁以外,还有活锁和饥饿者两种情 况

    4.9.4 饥饿

    很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,饥饿的情况不易演示,讲读写锁时会涉及饥饿问题下面我讲一下一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题,就是两个线程对两个不同的对象加锁的时候都使用相同的顺序进行加锁。 但是会产生饥饿问题Test29

    1594558469826

    顺序加锁的解决方案

    1594558499871

    4.10 ReentrantLock

    相对于 synchronized 它具备如下特点

    1. 可中断
    2. 可以设置超时时间
    3. 可以设置为公平锁
    4. 支持多个条件变量,即对与不满足条件的线程可以放到不同的集合中等待

    与 synchronized 一样,都支持可重入

    基本语法

    // 获取锁
    reentrantLock.lock();
    try {
     // 临界区
    } finally {
     // 释放锁
     reentrantLock.unlock();
    }
    
    

    可重入

    可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

    static ReentrantLock lock=new ReentrantLock();
    public static void main(String[]args){
            method1();
    }
    public static void method1(){
        lock.lock();
        try{
            log.debug("execute method1");
        method2();
        }finally{
            lock.unlock();
        }
    }
    public static void method2(){
        lock.lock();
        try{
            log.debug("execute method2");
             method3();
        }finally{
            lock.unlock();
        }
    }
    public static void method3(){
        lock.lock();
        try{
            log.debug("execute method3");
        }finally{
            lock.unlock();
        }
    }
    
    输出
    
    17:59:11.862 [main] c.TestReentrant - execute method1
    17:59:11.865 [main] c.TestReentrant - execute method2
    17:59:11.865 [main] c.TestReentrant - execute method3 

    可打断

    直接看例子:

    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(() -> {
        log.debug("启动...");
        try {
            lock.lockInterruptibly();
        } catch (InterruptedException e) {
            e.printStackTrace();
            log.debug("等锁的过程中被打断");
            return;
        }
        try {
            log.debug("获得了锁");
        } finally {
            lock.unlock();
            }
        }, "t1");
            lock.lock();
            log.debug("获得了锁");
            t1.start();
        try {
            sleep(1);
            t1.interrupt();
            log.debug("执行打断");
        } finally {
            lock.unlock();
        }
    }
    
    输出
    18:02:40.520 [main] c.TestInterrupt - 获得了锁
    18:02:40.524 [t1] c.TestInterrupt - 启动...
    18:02:41.530 [main] c.TestInterrupt - 执行打断
    java.lang.InterruptedException
    at
    java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchr
    onizer.java:898)
    at
    java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchron
    izer.java:1222)
    at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
    at cn.itcast.n4.reentrant.TestInterrupt.lambda$main$0(TestInterrupt.java:17)
    at java.lang.Thread.run(Thread.java:748)
    18:02:41.532 [t1] c.TestInterrupt - 等锁的过程中被打断
    
    
    注意如果是不可中断模式,那么即使使用了 interrupt 也不会让等待中断
    
    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(() -> {
        log.debug("启动...");
        lock.lock();
        try {
            log.debug("获得了锁");
        } finally {
            lock.unlock();
        }
    }, "t1");
    lock.lock();
    log.debug("获得了锁");
    t1.start();
    try {
        sleep(1);
        t1.interrupt();
        log.debug("执行打断");
        sleep(1);
    } finally {
        log.debug("释放了锁");
        lock.unlock();
    }
    
    输出
    
    
    18:06:56.261 [main] c.TestInterrupt - 获得了锁
    18:06:56.265 [t1] c.TestInterrupt - 启动...
    18:06:57.266 [main] c.TestInterrupt - 执行打断 // 这时 t1 并没有被真正打断, 而是仍继续等待锁
    18:06:58.267 [main] c.TestInterrupt - 释放了锁
    18:06:58.267 [t1] c.TestInterrupt - 获得了锁

    锁超时

    直接看例子

    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(() -> {
        log.debug("启动...");
        if (!lock.tryLock()) {
            log.debug("获取立刻失败,返回");
            return;
        }
        try {
            log.debug("获得了锁");
        } finally {
            lock.unlock();
        }
    }, "t1");
    lock.lock();
    log.debug("获得了锁");
    t1.start();
    try {
        sleep(2);
    } finally {
        lock.unlock();
    }
    
    输出
    
    18:15:02.918 [main] c.TestTimeout - 获得了锁
    18:15:02.921 [t1] c.TestTimeout - 启动...
    18:15:02.921 [t1] c.TestTimeout - 获取立刻失败,返回
    
    超时失败
    
    ReentrantLock lock=new ReentrantLock();
    Thread t1=new Thread(()->{
        log.debug("启动...");
    try{
        if(!lock.tryLock(1,TimeUnit.SECONDS)){
            log.debug("获取等待 1s 后失败,返回");
            return;
        }
    }catch(InterruptedException e){
        e.printStackTrace();
    }
    try{
        log.debug("获得了锁");
    }finally{
        lock.unlock();
    }
    },"t1");
    lock.lock();
    log.debug("获得了锁");
    t1.start();
    try{
        sleep(2);
    }finally{
        lock.unlock();
    }
    
    输出
    
    18:19:40.537 [main] c.TestTimeout - 获得了锁
    18:19:40.544 [t1] c.TestTimeout - 启动...
    18:19:41.547 [t1] c.TestTimeout - 获取等待 1s 后失败,返回
    
    使用 tryLock 解决哲学家就餐问题
    
    class Chopstick extends ReentrantLock {
        String name;
        public Chopstick(String name) {
            this.name = name;
        }
        @Override
        public String toString() {
            return "筷子{" + name + '}';
        }
    }
    
    class Philosopher extends Thread {
        Chopstick left;
        Chopstick right;
        public Philosopher(String name, Chopstick left, Chopstick right) {
            super(name);
            this.left = left;
            this.right = right;
        }
        @Override
        public void run() {
            while (true) {
                // 尝试获得左手筷子
                if (left.tryLock()) {
                    try {
                        // 尝试获得右手筷子
                        if (right.tryLock()) {
                            try {
                                eat();
                            } finally {
                                right.unlock();
                            }
                        }
                    } finally {
                        left.unlock();
                    }
                }
            }
        }
        private void eat() {
            log.debug("eating...");
            Sleeper.sleep(1);
        }
    }

    公平锁

    synchronized锁中,在entrylist等待的锁在竞争时不是按照先到先得来获取锁的,所以说synchronized锁时不公平的;ReentranLock锁默认是不公平的,但是可以通过设置实现公平锁。本意是为了解决之前提到的饥饿问题,但是公平锁一般没有必要,会降低并发度,使用trylock也可以实现。

    ReentrantLock lock = new ReentrantLock(false);
    lock.lock();
    for (int i = 0; i < 500; i++) {
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " running...");
            } finally {
                lock.unlock();
            }
        }, "t" + i).start();
    }
    // 1s 之后去争抢锁
    Thread.sleep(1000);
    new Thread(() -> {
    System.out.println(Thread.currentThread().getName() + " start...");
    lock.lock();
    try {
    System.out.println(Thread.currentThread().getName() + " running...");
    } finally {
    lock.unlock();
    }
    }, "强行插入").start();
    lock.unlock();
    
    强行插入,有机会在中间输出,注意:该实验不一定总能复现
    
    t39 running...
    t40 running...
    t41 running...
    t42 running...
    t43 running...
    强行插入 start...
    强行插入 running...
    t44 running...
    t45 running...
    t46 running...
    t47 running...
    t49 running...
    
    改为公平锁后
    
    ReentrantLock lock = new ReentrantLock(true);
    
    强行插入,总是在最后输出
    
    t465 running...
    t464 running...
    t477 running...
    t442 running...
    t468 running...
    t493 running...
    t482 running...
    t485 running...
    t481 running...
    强行插入 running...
    
    公平锁一般没有必要,会降低并发度,后面分析原理时会讲解


    条件变量

    synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待 ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比

    1. synchronized 是那些不满足条件的线程都在一间休息室等消息
    2. 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤 醒

    使用要点: 

    1. await 前需要获得锁
    2. await 执行后,会释放锁,进入 conditionObject 等待
    3. await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁,执行唤醒的线程爷必须先获得锁
    4. 竞争 lock 锁成功后,从 await 后继续执行
    static ReentrantLock lock=new ReentrantLock();
    static Condition waitCigaretteQueue=lock.newCondition();
    static Condition waitbreakfastQueue=lock.newCondition();
    static volatile boolean hasCigrette=false;
    static volatile boolean hasBreakfast=false;
    public static void main(String[]args){
    new Thread(()->{
        try{
            lock.lock();
            while(!hasCigrette){
                try{
                    waitCigaretteQueue.await();
                }catch(InterruptedException e){
                    e.printStackTrace();
                }
            }
            log.debug("等到了它的烟");
        }finally{
        lock.unlock();
        }
    }).start();
    new Thread(()->{
        try{
            lock.lock();
            while(!hasBreakfast){
                try{
                    waitbreakfastQueue.await();
                }catch(InterruptedException e){
                    e.printStackTrace();
                }
            }
            log.debug("等到了它的早餐");
        }finally{
            lock.unlock();
        }
    }).start();
        sleep(1);
        sendBreakfast();
        sleep(1);
        sendCigarette();
    }
    private static void sendCigarette(){
        lock.lock();
        try{
            log.debug("送烟来了");
            hasCigrette=true;
            waitCigaretteQueue.signal();
        }finally{
            lock.unlock();
        }
    }
    private static void sendBreakfast(){
        lock.lock();
        try{
            log.debug("送早餐来了");
            hasBreakfast=true;
            waitbreakfastQueue.signal();
        }finally{
            lock.unlock();
        }
    }
    输出
    18:52:27.680[main]c.TestCondition-送早餐来了
    18:52:27.682[Thread-1]c.TestCondition-等到了它的早餐
    18:52:28.683[main]c.TestCondition-送烟来了
    18:52:28.683[Thread-0]c.TestCondition-等到了它的烟
  • 相关阅读:
    html5分割上传实现超大文件无插件网页上传问题
    html5分割上传实现超大文件无插件网页上传方法
    spring JDBCTemplate 批量操作
    spring JDBCTemplate 查询操作
    spring JDBCTemplate 修改和删除
    spring JDBCTemplate 添加操作
    spring JDBCTemplate 概念和准备
    spring AOP 基于配置文件
    spring AOP 基于注解
    使用 流量统计 定位网络故障
  • 原文地址:https://www.cnblogs.com/chaojibaidu/p/14906663.html
Copyright © 2011-2022 走看看