并发的历史
操作系统的发展使得多个程序能够同时运行, 由操作系统来分配资源, 如果需要的话, 进程会通过一些原始的机制相互通信, 主要分为消息传递和共享内存两种:
- 消息传递: Socket, 信号处理(signal handlers), 信号量(semaphores)和文件等.
-
共享内存
线程共享其所属进程的内存地址空间, 因此所有同一进程中的线程访问相同的变量, 并从同一个堆中分配对象, 这相对于进程间通信(inter-process)机制来说实现了良好的数据共享. 但是如果没有明确的同步来管理共享数据, 一个线程可能会修改其他线程正在使用的数据, 产生意外的结果.
线程简介
线程优先级
Java线程中, 通过一个int
变量priority来控制优先级, 范围为1~10, 通过setPriority(int)
方法来修改优先级, 默认优先级是5, 操作系统通常会忽略自己手动设定的线程优先级(比如Mac OS X, Ubuntu等), 作为程序开发者一般也不需要手动调整
线程的状态
一共有6种可能的状态:
状态名称 | 说明 |
---|---|
NEW | 初始状态, 仅被构建, 没有启用start() 方法 |
RUNNABLE | 可运行状态, 将操作系统中定义的’就绪’和’运行’统称为’RUNNABLE’ |
BLOCKED | 阻塞状态, 需要获取锁 |
WAITING | 等待状态, 需要其他线程发出通知或者中断, 然后重新竞争锁 |
TIMED_WAITING | 定时等待状态, 和WAITING类似, 不过不会无限等待,具有一个超时自动返回的功能, 不会引起锁持有状况的变化 |
TERMINATED | 终止状态, 线程已执行完毕 |
可以使用jstack工具来进行查看运行时线程的信息, 步骤如下:
- 使用
jps
命令查看Java进程; - 找出当前运行程序的进程号, 这里以2100为例, 运行
jstack 2100
即可.
Java线程状态变迁示意图:
Daemon线程
Daemon线程是一种后台支持型线程, 因为它主要被用作程序中后台调度以及支持性工作.
当一个JVM中不存在非Daemon线程的时候, JVM将会立即退出(所以不能在finally
块中执行关闭或者清理资源的逻辑)
线程间通信
volatile和synchronized关键字
实际上是通过共享内存实现的线程间通信.
通知/等待机制
是任何Java对象都具备的, 这些方法定义在java.lang.Object
上:
方法名称 | 描述 |
---|---|
notify() |
通知一个在对象上等待的线程, 使其从wait() 方法返回, 而返回的前提是该线程获取到了对象的锁 |
notifyAll() |
通知所有等待在该对象上的线程 |
wait() |
调用该方法的线程进入WAITING状态, 同时释放对象锁, 只有等待另外线程的通知或被中断才会返回 |
wait(long) |
定时等待, 单位毫秒, 超时返回 |
wait(long, int) |
更细粒度的定时, 可以达到纳秒级别 |
等待/通知的经典范式
就是消费者/生产者模型
Thread.join()的使用
实际上是通知/等待机制的一种实现.
ThreadLocal的使用
是线程变量, 以ThreadLocal
对象为键, 任意对象为值的存储结构. 这个结构是与线程对象绑定的, 也就是说即使是使用线程不安全的数据结构, 如此包装后也可以安全使用.
并发编程中的挑战
并发编程中有许多单线程程序中没有的挑战.
上下文切换
即使是单核CPU也支持多线程执行代码, CPU会通过给每个线程分配CPU时间片来实现这个机制. 时间片一般是几十毫秒. 当前任务执行一个时间片后会切换到下一个任务, 但是在切换前会保存上一个任务的状态, 以便下次切换回这个任务时, 可以再加载这个任务的状态. 所以任务从保存到再加载的过程就是一次上下文切换. 上下文切换会影响多线程的执行速度.
并发执行的速度不一定比串行快, 因为线程有创建和上下文切换的开销. 当并发运算次数较少的时候, 上下文切换的开销会显著影响累积计算时间.
如何较少上下文切换
-
无锁并发编程
多线程竞争锁时, 会引起上下文切换, 避免使用锁的方法举例: 通过将数据的ID按照Hash算法取模分段, 不同的线程处理不同段的数据.
-
CAS算法
Java的
Atomic
包使用的就是CAS算法来更新数据, 不需要加锁, 举例:AutomicLong
类型 -
使用最少线程
避免大量线程处于等待状态
-
协程
在单线程里实现多任务的调度, 在单线程里维持多个任务间的切换.
死锁
避免死锁的几个方法
- 避免一个线程同时获取多个锁;
- 尝试使用定时锁, 使用
lock.tryLock(timeout)
来替代使用内部锁机制;
如何保证线程安全
使用无状态对象
无状态对象永远是线程安全的
这里以一个Servlet为例, 说明什么叫做无状态:
因为Servlet对象不包含属性, 也没有引用其他类的属性, 只有本地变量. 本地变量唯一的存储在本线程的栈中, 只有执行线程(本Servlet)才能访问, 所以是线程安全的.
使用原子操作
- 读-改-写 不是原子的, 例如
count++
- check-then-run, 不是原子的, 例如:
if (i != null) i = new SomeClass();
无状态+单个内置原子变量=线程安全, 但是两个及以上的原子变量!=线程安全
为了维持状态一致性, 必须将相关的状态变量在同一个原子操作内更新
注意
并不是说将一个类的所有方法都标注成synchronized
, 然后这个类就是线程安全的了, 举个例子, 如果两个属性之间需要同步更新, 然后分别提供了setter
方法, 那么在类外使用的时候, 就有可能引发安全问题.