谈谈多线程

谈谈多线程

多线程真的是一个很宽的话题,可以聊一串东西线程安全、同步机制、锁、线程运行状态、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。

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

  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

其中,不严谨地 把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对象及类加载机制、各种锁优化措施(偏向锁、自旋锁、轻量级锁、重量级锁)

  2. 可中断 vs 不可中断

  3. 线程阻塞状态BLOCKED vs WAITING

  4. 公平 vs 非公平

  5. 手工加锁(调用lock方法,finally代码块中调用unlock方法) vs JVM 自动加锁释放锁(monitorenter、monitorexit指令)

  6. 一个队列 vs 多个条件队列

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

上一篇:PAT 甲级测试题目 -- 1014 Waiting in Line


下一篇:【Java并发专题之二】Java线程基础