谈谈多线程

谈谈多线程

多线程真的是一个很宽的话题,可以聊一串东西线程安全、同步机制、锁、线程运行状态、CAS原子操作、线程池、甚至是JMM、内存可见性等。

而在日常coding中更多地关注是创建线程池提交多个任务执行,分析哪些数据结构被多个线程共享访问,在哪个方法上加锁?如果程序运行一段时间出问题,可能jstack查看线程堆栈执行信息、或者看dump出来的文件、或者用专业一点的工具检查hot区域代码等。日常讨论经常是陷入某个细节中,而这篇文章想从一个总体的角度记录一下我对多线程的理解。

现在的服务器都是多核的cat /proc/cpuinfo,如果我们写的代码只由一个线程执行的话效率是不高的,比如一个程序里面既要访问redis、又要发送http请求、另一个模块又会去查询MySQL……这些操作有些是可以并行的。用多个线程来执行,发挥cpu多核优势,程序执行效率就高了。

引入多线程后,不可避免地存在:

  1. 多个线程访问共享变量的情况

    new 了一个HashMap对象在JVM堆中,线程1读取Mysql中一些数据,保存到HashMap;线程2操作HashMap删除某些条件的数据,因此这个HashMap就是共享变量,为了保证数据一致性(线程安全),需要同步机制(也可以采用其他办法保证线程安全)。

  2. 多个线程之间竞争cpu

    cpu的核数是固定的,操作系统服务需要使用cpu,JVM虚拟机也要使用cpu(比如垃圾回收线程)、然后才是我们写的多线程应用程序也要使用cpu。操作系统一般采用抢占式调度,给每个线程分配时间片(最小执行时间),线程的时间片执行完了,将这个线程调度出去,换另一个线程使用cpu,这里的调度出去是被操作系统的线程调度器剥夺了cpu使用权,与多个线程争抢锁,未获得锁的线程被挂起剥夺cpu使用权是两码事。

总的说:多线程带来的三个问题:

  1. 安全性

    为了保证线程安全,有多种实现方式,这些实现方式是一个解决问题的方向,而不是针对某个具体问题的解法。

    • 互斥同步,采用加锁方式,比如synchronized或者juc包中的Lock实现类ReentrantLock。

      既然用锁来进行互斥同步,锁的实现方式也有多种多样,从不同的角度进行分类:

      乐观锁 vs 悲观锁

      轻量级锁(基于硬件原子指令) vs 重量级锁(一般伴随上下文切换)

    • 非阻塞同步,CAS操作,比如原子类AtomicLong

    • ThreadLocal,将共享变量转化成线程私有的变量。

    • 不可变类 将共享变量设计成不可变的。

  2. 活跃性

    redis 分布式锁官方文档提到,锁的2个基本保证:安全性和活性。活跃性可进一步理解成:

    • 死锁
    • 活锁 两个线程相互谦让,导致双方都不能继续执行下去
    • 饥饿 在非公平锁竞争中,处于等待队列中的线程一直获取不到锁

    正是程序运行不当,存在活跃性,导致了程序的性能问题。

  3. 性能问题

其实谈到了锁,又不免地想把JAVA里面的锁与数据库锁对比一下。说起JAVA里面的锁,想到的是synchronized、ReentrantLock、AtomicInteger CAS操作,而数据库里面的锁,则是与事务相关。事务有ACID4个特性,其中隔离性(各个事务操作对象相互分离,在事务提交前对其他事务不可见)就是由锁来保证实现的。这个时候,就站在了一个更细的角度来讨论锁了,比如:锁的粒度对并发的影响 vs 锁的粒度(快照、记录锁、区间锁)与事务隔离级别之间的关系、锁的类型(读锁、写锁、共享锁、排他锁)、死锁检测机制(可中断 vs 不可中断)

再回到多线程,引入多线程带来的开销:

  1. 上下文切换

    线程占用cpu执行,会加载数据到cpu缓存、会访问寄存器,切换到另一个线程占用cpu时,这些上下文信息都得保存起来,涉及到应用态到内核态的切换,这些算是:是上下文切换开销。为什么调度器会给线程分配一个最小执行时间?,确保线程至少会执行一段时间,而不是刚占用cpu就立即被调度出去了。

  2. 内存同步

    同步操作可以保证内存可见性(volatile、synchronized),它们会使用一些特殊的指令:Memeory Barrier(内存栅栏)刷新缓存(JMM 内存模型:主内存 vs 线程的工作内存)、阻止编译器优化,这些都会影响性能。

  3. 阻塞的代价

    在锁上发生竞争时,竞争失败的线程会阻塞。有没有想过这个阻塞到底是啥子?或者说:阻塞是怎么实现的? 引用《Java并发编程实战》一句话:

    JVM 在实现阻塞行为时,可以采用自旋等待 或者 通过操作系统挂起被阻塞的线程

    那么阻塞的代价就里:自旋占用cpu时钟周期、挂起线程导致上下文切换。

    当线程无法获取锁,或者在某个条件上等待或者等待IO操作完成时,需要挂起。这个过程涉及到上下文切换、操作系统操作以及必要的缓存操作,被阻塞的线程在其时间片尚未用完前就被调度出去了。

    当其他线程释放了锁,锁重新变得可用了,或者IO操作等待的数据已经完成加载到用户缓冲区了,或者其他线程等待条件已经满足了,这个线程又被切换回来

既然引入了多线程,采用了锁来保证线程安全,线程获取锁失败就会进入阻塞状态,但是获取锁的流程还在继续,等到持有锁的线程释放锁后,就会唤醒线程,因此线程的状态就会发生变化,java.lang.Thread.State定义了6种状态:

  1. NEW

    创建了一个线程,还没有执行Thread#start()方法

  2. RUNNABLE

    向线程池中提交任务执行,任务会"排队"等待cpu调度执行,此时线程就是RUNNABLE,正在占用cpu执行的线程一般叫做 RUNNING

  3. BLOCKED

    当线程争抢监视器(synchronized)失败时,进入BLOKCED状态。对于 synchronized而言,竞争锁失败的线程进入BLOCKED状态,并且不可响应中断,而 ReentrantLock 有一个 lockInterruptibly方法,在竞争锁过程中能够响应中断,从而退出WAITING状态。

  4. WAITING

    线程等待另一个线程执行某个特定操作时,进入WAITING状态。比如LinkedBlockingQueue,如果队列中没有数据,那么消费者线程执行take()方法就会进入到WAITING状态;再比如多个线程执行同一个ReentrantLock对象的lock()方法,只会有一个线程获得锁,其他线程进入WAITING状态;

  5. TIMED_WAITING

    线程执行 Thread.sleep(mills),sleep一段时间,进入TIMED_WAITING状态。又比如,ReentrantLock有一个tryLock(timeout),竞争锁失败的线程进入TIMED_WAITING状态,阻塞一段时间,然后又恢复到RUNNABLE。

  6. TERMINATED

在日常的开发中,我们最关心的是 jstack 打印出程序堆栈信息时,如何根据堆栈信息中线程的状态分析线程执行了什么操作?为什么是WAITING状态?为什么是BLOCKED状态? 这里我想区分一下:程序中使用锁的方式有两种(实在想不到其他好的表达方法了),一种是各个线程竞争锁,拿到锁的线程进入临界区执行,执行完毕尽快退出,在执行临界区中的代码时,不会出现因条件不满足需要让出锁的情况;另一种刚好相反:线程在持有锁之后,还需要检查状态条件,如果条件不满足,就需要释放锁,阻塞,当条件满足之后,再重新竞争锁。
比如,消费者线程 执行 LinkedBlockingQueue的take()方法时,先要获取队列的take锁,拿到锁的线程还需要检查“队列里面是有数据的”这一条件,这样才能取数据。那么,如果此时队列里面没数据呢?线程就会 释放take锁,进入WAITING状态,阻塞了(调用await方法)。因此,只能等到生产者线程往队列里面添加数据之后,唤醒(调用signal方法)阻塞的消费者线程。(这里引入了一个概念,叫条件队列,具体参考第14章),然后被唤醒的消费者线程需要重新竞争锁
再比如,多个线程调用synchronized修饰的方法,该方法里面只是访问共享变量而已,没有依赖状态的检查,那么未获得锁的线程进入的是BLOCKED状态,而且这种方式下,最多只有饥饿问题,不会产生死锁问题。
因此,再去看JDK java.lang.Thread.State描述线程状态的注释,我觉得:如果使用synchronized锁竞争时,未持有锁的线程会进入BLOCKED状态。如果 持有synchronized锁的线程还需要检查条件状态,如果条件又不满足,这里会调用Object.wait()释放锁,并把自己挂起,进入阻塞,线程的状态也就变成BLOCKED了。

Thread state for a thread blocked waiting for a monitor lock. A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling Object#wait()
我想,这里的 reenter a synchronized block/method after calling Object#wait() 对应的情形就是:线程一开始持有synchronized锁,然后需要检查条件状态,发现条件状态不满足,于是调用 Object.wait 释放锁,并且进入阻塞BLOCKED状态,当有其他线程调用 signal 唤醒该线程时,它又重新竞争synchronized锁。

其中,"不严谨"地 把BLOCKED、WAITING、TIMED_WAITING 称为阻塞状态,线程在阻塞状态下,能不能响应中断?这个问题更宽泛一点就是:如何取消线程所执行的任务?

  • java.util.concurrent.ExecutorService 通过submit方法提交Runnable任务或者Callable任务返回一个Future对象,通过Future.cancel方法取消任务
  • 线程可响应中断,通过中断的方式取消任务
  • 线程不可响应中断,比如执行阻塞IO、阻塞 SocketIO 进入了WAITING状态,无法响应中断,这时可通过关闭底层Socket连接、强行关闭已打开的文件描述符都会强制中断线程,退出WAITING状态。

另外引出的另一个问题就是:synchronized 监视器锁与juc包中的Lock实现类ReentrantLock的对比:

  1. 实现方式 monitor 对象 vs AQS

    谈到monitor对象,又联想到对象的内存结构:对象头、实例数据、对齐填充。对象头里面又存储着:对象的hash码、GC分代年龄、类型指针(class对象)、markword……那么这里又涉及到:JVM垃圾回收和内存布局、Class对象及类加载机制、各种锁优化措施(偏向锁、自旋锁、轻量级锁、重量级锁)。但其实二者都是基于条件队列来实现的,关于条件队列,参考《JAVA并发编程实战》p244

  2. 可中断 vs 不可中断

  3. 线程阻塞状态BLOCKED vs WAITING

  4. 公平 vs 非公平

  5. 手工加锁(调用lock方法,finally代码块中调用unlock方法) vs JVM 自动加锁释放锁(monitorenter、monitorexit指令)。因此,在 使用 ReentrantLock 都是 try-finally,因为如果在线程获得了锁但是执行过程中出现了异常,持有的锁需要在finally代码块中调用 unlock()方法释放,如果线程在执行过程中抛出了异常又未执行unlock() 方法,那么锁将无法释放了。,而线程在获得了 synchronized 锁后,执行过程中抛出了异常,那么 锁将自动被释放。

  6. synchronized隐式地关联一个队列 vs ReentrantLock 通过 new Condition 可以显示地 关联 多个 条件队列(条件队列是实现阻塞的最佳方式。当线程检查某个条件不满足而无法继续执行时,可以通过 while(true)轮询+sleep 一段时间 实现阻塞;也可以通过 cpu 自旋一段时间达到阻塞效果;也可以通过引入条件队列,让线程在条件队列上等待,当条件满足时由操作系统通知线程重新竞争锁,条件队列这种通知唤醒机制是最高效的)。
    注意:阻塞有2种情形,一种是:尚未持有锁的线程,都在竞争锁,那些竞争失败的线程进入阻塞状态(BLOCKED or WAITING);另一种是:已经持有锁的线程检查某个条件,条件未满足,需要放弃锁让出cpu进入阻塞状态(WAITING),比如LinkedBlockingQueue#take()方法,首先持有 take 锁,并且只有当 LinkedBlockingQueue不为空 这个条件成立时,才能 take 一个元素。

  7. 灵活性。tryLock、tryLock(timeout)

总结

有时候,经常把多线程、锁、同步 这些概念搞混淆起来,要解释一个概念挺难的,尤其是这种熟悉的概念。因此这篇文章不是聚焦于一个点去深入解释某个概念,而是侧重于各个概念之间的联系,里面有很多值得深入的地方,比如讨论数据库事务的ACID的实现方式,锁与隔离级别之间的关系、再比如synchronized底层实现原理与AQS之间的不同、再比如线程池提交任务的顺序、线程池参数、饱和策略(拒绝策略)……本文就当做一个大纲吧。

原文链接:https://www.cnblogs.com/hapjin/p/12040107.html

posted @ 2019-12-14 16:44  大熊猫同学  阅读(850)  评论(6编辑  收藏  举报