Java多线程编程实战指南-核心篇

1 多线程编程基础

1.1 进程与程序

进程是程序的运行实例,类似播放中的视频和视频文件的关系

1.2 创建线程的两种方式
  1. 继承Thread重写run方法
  2. 提供Thread对象一个Runnable对象
1.3 Thread与Rnnable的关系

Thread是对线程的抽象,Runnable是对线程要执行的任务的抽象

1.4 线程的属性
  1. ID:不适合用作某种唯一标识
  2. Name:有助于代码调试和问题定位
  3. Daemon
    1. true表示守护线程,false表示用户线程
    2. main方法由用户线程执行
    3. Java虚拟机只有在所有用户线程都运行结束(Thread.run结束)后才能正常停止,而守护线程不影响虚拟机正常停止
    4. kill -9不属于正常停止,而是强制停止,因此无论用户线程是否都结束,kill -9都能停止Java虚拟机
  4. Prioriy:一般设置为默认优先级即可
1.5 Thread常用方法
  1. static currentThread:返回当前线程对象
  2. start:启动线程
  3. join:线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束
  4. sleep:使当前线程暂停运行指定的时间
  5. 不可靠的方法:yield
  6. 废弃的方法:stop、suspend、resume
1.6 Java中的线程
  1. main线程
  2. Java垃圾回收线程
  3. JIT编译器将Java字节码编译成处理器可以直接执行的机器码使用的线程
1.7 Java的线程生命周期
  1. 通过Thread.getState()可以获取线程状态
  2. 活跃线程:READY状态的线程
  3. Thread.getState的返回值中是不包含READY和RUNNING的,这两个状态统一返回RUNNABLE,READY和RUNNING只是操作系统中对线程状态的细化
  4. 精确来说,从BLOCKED、WAITING、TIMED_WAITING恢复成READY而不是RUNNING
  5. 时间分片结束也会从RUNNING变为READY

在这里插入图片描述

1.8 线程转储(Thread Dump)
  1. 线程转储包含了获取这个线程转储的那一刻该程序的所有线程信息
  2. 获取方式
    1. Oracle JDK:jstack -l pid
    2. IBM JDK
      1. 系统宕机自动产生,使用jca467.jar分析
      2. java -jar surgery.jar -pid pid -command JavaDump
    3. WebLogic:监视–线程–转储线程堆栈
    4. linux:kill -3 pid
1.9 多线程的优势
  1. 充分利用多核处理器:一个线程同一时间只能被一个处理器处理
  2. 提高系统吞吐率:IO等待时可以先执行其他线程
  3. 提高响应性:一个请求慢了不会影响其他请求
  4. 最小化对系统资源的使用:多线程公用一块内存
1.10 多线程的风险
  1. 线程安全问题
  2. 线程活性问题
  3. 上下文切换:处理器从执行一个线程转向执行另一个线程时,操作系统需要执行的动作
  4. 可靠性:线程如果内存溢出,会导致整个进程瘫痪,进而进程中其他线程也无法正常执行

2 线程编程的目标与挑战

2.1 串行、并发与并行
  1. 串行:1个人,先做A事情,A结束才能继续做B事情
  2. 并发:1个人,先做A事情的准备工作,等待A结束这段时间,做B事情
  3. 并行:2个人,一个人做A事情,一个人做B事情
  4. 1个cpu处理多个线程是并发,N个cpu处理N个线程是并行
  5. 多线程的本质就是将串行改为并发
2.2 竞态(race condition)
  1. 竞态:指一种现象,一个程序的输出依赖于不受控制的事件出现顺序或者出现时机
  2. 竞态产生原因:多个线程对同一组共享变量的读取和更新操作交错进行,也就是不具备原子性
  3. 可能产生竞态的模式
    1. read-modify-write:sequence++
    2. check-then-act:条件语句
  4. 消除竞态,其实就是保证原子性
    1. 同一时刻只有一个线程对共享变量进行读或写:利用锁的排他性
    2. 多个线程不使用共享变量
2.3 线程安全
  1. 所谓线程安全,就是线程的执行结果和预期的一致(运作正常)
  2. 线程安全的类:就是多个线程中可以使用该类的同一个对象,最终使用的结果和预期的一致
  3. 竞态会导致线程不安全,但线程不安全不一定都是由于竞态导致
  4. 常见线程不安全的类
    1. ArrayList:插入数据丢失
    2. HashMap:死循环和内存泄漏
    3. SimpleDateFormat
  5. 将类做成线程安全的类需要额外代价
  6. 状态变量:实例变量或静态变量
  7. 共享变量:有可能被多线程共同访问的变量,状态变量就是共享变量
  8. 导致线程不安全的几个方面
    1. 原子性:对于同一组共享变量读写的两块操作对于对方来说如果不具备原子性就可能导致线程不安全
      1. 一定是对同一组共享变量,因为如果两个操作读写的不是同一组共享变量,甚至读写的是局部变量,那么即使两个操作之间不具备原子性,交错执行,也不会导致线程不安全
    2. 有序性:如果一个线程的访问一组共享变量的操作,感受到的另一个线程对这组共享变量的内存访问操作与源码顺序不同,就可能导致线程不安全,通常来说对多个共享变量更新或对单个共享变量多次更新,才会导致有序性造成线程安全问题
    3. 可见性:如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程无法立刻读取到该更新的结果,就可能导致线程不安全
2.4 原子性
2.4.1 概念
  1. 本意是指不可分割的性质
  2. 原子操作:本意指不可分割的操作,例如sequence = 10是原子操作,sequence++不是原子操作,因为该操作可拆为如下3个操作
    1. load(sequence, r1); // 指令①:将变量sequence的值从内存读到寄存器r1
    2. increment(r1); // 指令②:将寄存器r1的值增加1
    3. store(sequence, r1); // 指令③:将寄存器r1的内容写入变量sequence所对应的内存空间
  3. 多线程编程中所谓的原子性:指不同线程上执行的两块访问同一组共享变量操作,它们对于对方来说可以看成一个操作,无法分割,即两块操作间无法交错执行,就认为这两块操作对于对方来说具备原子性
    1. A操作对于B操作为原子操作,不见得对C操作也是原子操作,因为很可能A和C可以交错执行
2.4.2 Java如何保证两块操作对于对方来说具备原子性
  1. 通过为两块操作加同一个锁:利用锁的排他性保证两块操作只能串行执行,也就不会出现交错执行的问题,也因此这两块操作对于对方来说具备原子性
  2. 使用CAS构造的代码
  3. 因此为了避免原子性引起的线程不安全,只需为可能在多个线程上执行的,对于同一组共享变量读写的两块操作,加上同一个锁,或使用CAS构造的代码替代对于对方来说不具备原子性的操作即可
2.4.3 Java读写操作的原子性
  1. 对任何类型的变量的读操作都具备原子性
  2. 对除long型和double型以外的任何类型的变量的写操作都具备原子性
    1. 也就是说A线程将long型的变量a的值写为5时,由于写long型变量不具备原子性,因此写入一半时,可能执行B线程读变量a的操作,导致读取到的值既不是初始值0,也不是最终的值5
  3. 如果使用volatile关键字修饰long和double类型的变量可以保证该变量的写也具备原子性
2.4.4 避免原子性带来的线程安全问题的最佳实践
  1. 考虑对同一组共享变量的读写加同一把锁
2.5 可见性
2.5.1 概念
  1. 如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程可以立刻读取到该更新的结果,那么我们就称这个线程对该共享变量的更新对其他线程可见,否则称为不可见
  2. 默认情况下,一个线程对共享变量的更新对其他线程不可见
  3. 一个线程更新了该变量的值之后,其他线程能够立即读取到这个更新后的值,那么这个值就被称为该变量的相对新值 。如果读取这个共享变量的线程在读取并使用该变量的时候其他线程无法更新该变量的值(被加锁了),那么该线程读取到的相对新值就被称为该变量的最新值
2.5.2 不可见的原因
  1. JIT编译器对代码进行了优化

    while (! toCancel) {
         
      if (doExecute()) {
         
        break;
      }
    }
    
    //JIT编译器可能会为了避免重复读取状态变量toCancel以提高代码的运行效率,对代码进行优化
    //这就导致即使toCancel被另一个线程修改,当前线程也确实看到了这个修改,也无法跳出循环
    //经过测试,doExecute中有系统调用,类似sout、Thread.sleep时,并不会被优化
    if (! toCancel) {
         
      while (true) {
         
        if (doExecute()) {
         
            break;
        }
      }
    }
    
  2. 与计算机的存储子系统有关

    1. 每个cpu有各自的寄存器、高速缓存,但共用主存,即都能读取到主存中的数据
    2. cpu只能对寄存器中数据进行运算,因此读变量时,cpu会先将数据从高速缓存复制到寄存器,如果数据不在高速缓存,那么需要先从主存复制到高速缓存再复制到寄存器,写变量时相反,因此各cpu读写的数据其实是主存中数据的副本
    3. 一个cpu无法读取到另一个cpu上寄存器中的内容,但当发现自身高速缓存中数据无效后,可以读到另一个cpu高速缓存中的内容(通过缓存一致性协议进行缓存同步)
    4. 由于缓存一致性带来的性能问题,cpu引入写缓冲器和无效化队列,对数据的修改会先放入写缓冲器,而读取数据也会先从写缓冲器中读取,同时一旦接到通知,发现自身高速缓存中某些数据无效,不会直接清理高速缓存中内容,而是先将这些内容放入自身的无效化队列中,等到清理无效化队列时,才真正将高速缓存中对应数据清除
    5. 一个变量可能被分配到寄存器中存储,也可能分配到主存中存储
      1. 如果在寄存器:一个cpu进行了修改,另一个cpu中不可见
      2. 如果在主存:一个cpu进行了修改,会先将数据放入自身的写缓冲器,在使用写缓冲器中数据更新高速缓存(冲刷处理器缓存)中数据之前,另一个cpu不可见,即使冲刷处理器,但另一个cpu在清空自身无效化队列前(刷新处理器缓存),不会发现自身高速缓存中数据无效,仍然会从自身的写缓冲器或高速缓存中读取数据,仍然看不见另一个cpu对该数据的更改
2.5.3 保障可见性
  1. 修改数据的cpu在修改数据后,立即冲刷处理器,即清空写缓冲器
  2. 读取数据的cpu在读取数据前,刷新处理器缓存,即清空无效化队列
2.5.4 Java中保障可见性
  1. 使用volatile修饰共享变量

    1. 可以阻止JIT编译器的错误优化
    2. volatile修饰的变量的写操作后,会强制cpu冲刷处理器缓存
    3. volatile修饰的变量的读操作前,会强制cpu刷新处理器缓存
  2. 使用锁

    1. 获取锁后,临界区前,会强制cpu冲刷处理器缓存
    2. 临界区后,释放锁前,会强制cpu冲刷处理器缓存
  3. 父线程在启动子线程之前对共享变量的更新对于子线程来说可见的

    public class ThreadStartVisibility {
         
      static int data = 0;
    
      public static void main(String[] args) {
         
    
        Thread thread = new Thread() {
         
          @Override
          public void run() {
         
            Tools.randomPause(50);
            //打印的值可能是1或2,但一定不是0
            System.out.println(data);
          }
        };
    
        // 在子线程thread启动前更新变量data的值,对子线程可见
        data = 1;
        thread.start();
        Tools.randomPause(50);
        // 在子线程thread启动后更新变量data的值,不可见
        data = 2;
      }
    }
    
  4. 一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言可见

    public class ThreadJoinVisibility {
         
      static int data = 0;
    
      public static void main(String[] args) {
         
        Thread thread = new Thread() {
         
          @Override
          public void run() {
         
            Tools.randomPause(50);
            data = 1;
          }
        };
        thread.start();
        try {
         
          thread.join();
        } catch (InterruptedException e) {
         
          e.printStackTrace();
        }
        //打印的一定是1
        System.out.println(data);
      }
    }
    
2.5.5 避免可见性带来的线程安全问题的最佳实践
  1. 考虑为需要可见的共享变量的读写操作加锁,或使用volatile修饰该共享变量
2.6 有序性
2.6.1 概念
  1. 指一个处理器上运行的一个线程所执行的对一组共享变量的内存访问操作在另外一个处理器上运行的其他线程的访问这组共享变量的操作看来是否与源码顺序相同
    1. 对于另一个处理器上,非访问共享变量的操作,即使看来无序,也不会影响线程安全
2.6.2 重排序

重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能,但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题

2.6.3 指令重排序
  1. 编译器:Java中编译器(javac)不会造成重排序,即字节码和源码顺序一定一致
  2. JIT编译器:编译后的机器码可能和字节码顺序不一致,例如DCL单例
  3. CPU:CPU对机器码的执行顺序可能和机器码本身的顺序不一致,例如if中内容会先执行
2.6.4 存储子系统重排序
  1. 存储子系统重排序也称为内存重排序
  2. 内存重排序是由于写缓冲器和无效队列引起的,写线程中先写入的变量后进入高速缓存,另一个线程就会感觉写操作排到了后面,或读线程中,后读取的变量在无效化队列,另一个线程就会感觉读操作排到了前面,从而产生内存重排序
2.6.5 貌似串行语义
  1. 如果所有内容都可以肆无忌惮的重排序,那么单线程下程序执行结果都可能出现问题
  2. 因此重排序会遵守一定的规则,至少保证单线程下的执行结果不会因为重排序而出现问题
    1. 例如对同一变量的读操作和写操作不能重排序
  3. 在这种规则限制的前提下,单线程执行的结果不会出现问题,因此在单线程看来,根本没有重排序,程序是一条条串行执行的,因此叫貌似串行语义
2.6.6 避免可见性带来的线程安全问题的最佳实践
  1. 不必禁止所有重排序,只需禁止那些导致线程安全问题的重排序即可
  2. 只需从逻辑上禁止重排序即可,而不是从物理上禁止重排序
  3. 底层使用内存屏障,Java层使用volatile和synchronized关键字
2.7 上下文切换
  1. 上下文:线程的进度信息,通常包括通用寄存器的内容和程序计数器的内容

  2. 当一个线程被暂停,被剥夺处理器的使用权(切出),另外一个线程被选中开始或者继续运行(切入),就会发生上下文的切换,将切出线程的上下文保存到内存,将切入线程在内存中的上下文信息恢复到寄存器和程序计数器中

  3. Java中,发生上下文切换的场景

    1. RUNNABLE --> 非RUNNABLE:线程被暂停
    2. 非RUNNABLE --> RUNNABLE:线程被唤醒
  4. 需要注意虽然Java中没有READY和RUNNING状态,但如果非细分一下,非RUNNABLE --> READY,不会发生上下文切换,从READY --> RUNNING才会发生上下文切换

  5. 上下文切换的开销

    1. 操作系统保存和恢复上下文所需的开销
    2. 线程调度器进行线程调度的开销
    3. 处理器高速缓存重新加载的开销:因为一个处理器执行一半的线程可能会交给另一个处理器执行,那么另一个处理器需要通过高速缓存获取该线程在原处理器高速缓存中的上下文
    4. 一级高速缓存中的内容被冲刷进二级缓存甚至主存
  6. 监控Java程序上下文切换的次数和频率

    #使用linux内核提供的perf命令
    perf stat -e cpu-clock, task-clock, cs, cache-references, cache-misses java io.github.viscent.mtia.ch1.FileDownloaderApp http://server.com/a.png http://server.net/b.png http://server.info/c.png
    
    #结果为653次,0.004M次每秒
    653 cs                        #   0.004 M/sec
    
2.8 资源争用与调度
  1. 排他性资源:一次只能够被一个线程占用的资源,例如cpu、文件、数据库连接

  2. 争用:多个线程想访问同一个排他性资源

  3. 资源调度:选择哪个申请者占用资源

  4. 资源持有线程:获得资源的独占权而又未释放其独占权的线程

  5. 资源调度策略:以锁为例

    1. 资源调度器内部维护一个等待队列
    2. 申请资源失败的线程会被暂停,并放入等待队列
    3. 当资源被其持有线程释放,将等待队列中的一个线程唤醒,变为READY状态
    4. 当其变为RUNNING,再次申请资源,如果失败又会被暂停,如果成功,则移出队列
    5. 非公平:随机唤醒一个线程,且与其他RUNNABLE线程(未进入过队列)都有机会先被CPU选中执行,从而申请到资源,而且其他RUNNABLE线程申请到资源概率更大
    6. 公平:唤醒队列头部的线程(也可能是队列中某个线程),其他RUNNABLE线程发现等待队列中有线程,就直接被暂停并放入队列尾部,因此根本不会被CPU选中执行,因此被唤醒的线程一定能申请到资源
  6. 公平与非公平策略特点与选择

    1. 公平
      1. 吞吐率低:因为想申请资源的线程,必须排到队列尾部并暂停,一定导致上下文切换,而如果是非公平策略,其他RUNNABLE线程可能会直接获取到锁,而且如果在队列中被唤醒的线程申请资源前,如果就能执行完成并将资源释放,那么队列中被唤醒的线程就不会再次被暂停,减少上下文切换次数,提升了吞吐率
      2. 资源申请者申请资源所需的时间基本相同
      3. 适用于每个线程占用资源时间都较长,或要求源申请者申请资源所需的时间基本相同的情况
    2. 非公平
      1. 吞吐率高
      2. 资源申请者申请资源所需的时间偏差可能较大,并可能导致饥饿现象
      3. 默认都应使用非公平策略

3 Java线程同步机制

3.1 线程同步机制
  1. 协调线程间的数据访问及活动的机制,该机制用于保障线程安全的机制
  2. Java平台提供的线程安全机制
    1. volatile关键字
    2. final关键字
    3. static关键字
    4. 相关的API
3.2 锁
  1. 临界区:获得锁和释放锁之间的代码,称为对应锁引导的临界区,该锁称为该临界区的引导锁
  2. 分类
    1. 内部锁:synchronized
    2. 显示锁:Lock接口,ReentrantLock实现,CAS实现
3.2.1 锁的作用
  1. 锁如何保障线程安全
    1. 原子性:通过互斥保障原子性
    2. 可见性:锁的获得隐含着刷新处理器缓存,释放隐含着冲刷处理器缓存
    3. 有序性:锁的获取无法与临界区内代码重排序,锁的释放也不能与临界区内代码重排序,再通过原子性和可见性,就共同保障了有序性,即虽然临界区内外各自还是能重排序,但不会导致其他线程看来当前线程临界区代码执行顺序与源码顺序不同
  2. 锁保障线程安全的前提是,多个线程在访问同一组共享数据的时候必须使用同一个锁,且仅读共享数据而没有写的情况下,读操作也需要加锁
3.2.2 锁相关概念
  1. 可重入性:一个线程在其持有一个锁的时候是否可以再次申请该锁
  2. 可重入锁
    1. 可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值为0,表示相应的锁还没有被任何线程持有
    2. 每次线程获得一个可重入锁的时候,该锁的计数器值会被增加1
    3. 每次一个线程释放锁的时候,该锁的计数器属性值就会被减1
    4. 一个可重入锁的持有线程初次获得该锁时相应的开销相对大,这是因为该锁的持有线程必须与其他线程“竞争”以获得锁
    5. 可重入锁的持有线程继续获得相应锁所产生的开销要小得多,这是因为此时Java虚拟机只需要将相应锁的计数器属性值增加1即可以实现锁的获得
    6. synchronized和ReentrantLock都是可重入锁
  3. 锁的争用与调度:因为锁也是排他性资源,因此也有公平和非公平两种策略
  4. 锁的粒度:一个锁实例所保护的共享数据的数量大小,粒度过粗会导致不必要的等待,过细会增加锁调度的开销
3.2.3 锁的开销
  1. 申请释放的开销
  2. 上下文切换的开销
  3. 锁泄漏:由于程序错误导致锁一直无法释放
3.3 内部锁
  1. Java平台中,任何一个对象,都有唯一一个和其关联的锁,称为监视器,或内部锁

  2. 内部锁通过synchronized关键字实现

  3. 同步方法:synchronized修饰的方法

    1. static方法:引导锁为static方法所在类对应的Class对象关联的锁
    2. 非static方法:引导锁为调用该方法的对象关联的锁
  4. 同步块:synchronized修饰的代码块,引导锁为锁句柄这个对象关联的锁

    //通常使用private final修饰,防止被修改,从而无法保证线程安全
    synchronized(锁句柄) {
         
    
    }
    
  5. 内部锁的使用不会引起锁泄漏,因为锁的获取和释放都由java完成,类似在finally中释放锁

  6. 内部锁的申请、释放、调度都由Java虚拟机负责代为实施,是非公平策略

3.4 显式锁
  1. java1.5后提供,将显式锁抽象为Lock接口,ReentrantLock是Lock接口的默认实现

  2. 使用模版

    private final Lock lock = new ReentrantLock();
    
    lock.lock();
    try{
         
      //临界区
    }finally {
         
      lock.unlock();
    }
    
  3. ReentrantLock默认为非公平锁,可以通过new ReentrantLock(true)创建公平锁

  4. 显示锁优缺点

    1. 编程灵活:内部锁的申请与释放只能是在一个方法内进行,而显式锁支持在一个方法内申请锁,却在另外一个方法里释放锁

    2. 锁泄漏:内部锁不会锁泄漏,但显示锁会

    3. 显示锁可以避免无法获取锁造成的代码无法继续

      private final Lock lock = new ReentrantLock();
      if (lock.tryLock()) {
        try {
          //临界区
        } finally {
          lock.unlock();
        }
      } else {
        //获取锁失败时的操作,不必等待
      }
      
    4. 公平:显示锁既支持公平锁,又支持非公平锁,而内部锁只支持非公平锁

    5. 打印锁的相关信息:显式锁提供了一些接口(指方法)可以用来对锁的相关信息进行监控,例如isLocked,getQueueLength

    6. 性能

      1. Java 1.6/1.7对内部锁做了一些优化,这些优化在特定情况下可以减少锁的开销
        1. 锁消除
        2. 锁粗化
        3. 偏向锁
        4. 适配性锁
      2. Java 1.5中,在高争用的情况下,内部锁的性能急剧下降,而显式锁的性能下降则少得多,即显式锁可伸缩性好,但Java1.6后,由于进行了优化,可伸缩性差异已经很小
  5. 读写锁

    1. 读线程:对共享变量仅进行读取而没有进行更新的线程

    2. 写线程:对共享变量进行更新的线程

    3. 读写锁中包含读锁和写锁

      1. 读锁:共享的,即有线程持有读锁时,其他线程可以获取读锁,但无法获取写锁(因为写锁排他)
      2. 写锁:排他的,有线程持有写锁时,其他线程既无法获取写锁,也无法获取读锁
    4. Java中使用ReadWriteLock表示读写锁,ReentrantReadWriteLock为其默认实现

    5. 其有两个方法,readLock和writeLock分别用于返回相应读写锁实例的读锁和写锁,并不是返回两种锁,而是表示同一个锁可以充当两种角色

    6. 示例

      public class ReadWriteLockUsage {
             
        //final表示防止被修改,导致两个线程获取的不是同一个锁
        private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
        //创建读锁
        private final Lock readLock = rwLock.readLock();
        //创建写锁
        private final Lock writeLock = rwLock.writeLock();
        //读线程的方法
        public void
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值