Java并发编程 - 基础

时间:2022-10-24 20:06:21

并发的历史

操作系统的发展使得多个程序能够同时运行, 由操作系统来分配资源, 如果需要的话, 进程会通过一些原始的机制相互通信, 主要分为消息传递和共享内存两种:

  • 消息传递: 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工具来进行查看运行时线程的信息, 步骤如下:

  1. 使用jps命令查看Java进程;
  2. 找出当前运行程序的进程号, 这里以2100为例, 运行jstack 2100即可.

Java线程状态变迁示意图:

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时间片来实现这个机制. 时间片一般是几十毫秒. 当前任务执行一个时间片后会切换到下一个任务, 但是在切换前会保存上一个任务的状态, 以便下次切换回这个任务时, 可以再加载这个任务的状态. 所以任务从保存到再加载的过程就是一次上下文切换. 上下文切换会影响多线程的执行速度.

并发执行的速度不一定比串行快, 因为线程有创建和上下文切换的开销. 当并发运算次数较少的时候, 上下文切换的开销会显著影响累积计算时间.

如何较少上下文切换

  1. 无锁并发编程

    多线程竞争锁时, 会引起上下文切换, 避免使用锁的方法举例: 通过将数据的ID按照Hash算法取模分段, 不同的线程处理不同段的数据.

  2. CAS算法

    Java的Atomic包使用的就是CAS算法来更新数据, 不需要加锁, 举例: AutomicLong类型

  3. 使用最少线程

    避免大量线程处于等待状态

  4. 协程

    在单线程里实现多任务的调度, 在单线程里维持多个任务间的切换.

死锁

避免死锁的几个方法

  • 避免一个线程同时获取多个锁;
  • 尝试使用定时锁, 使用lock.tryLock(timeout)来替代使用内部锁机制;

如何保证线程安全

使用无状态对象

无状态对象永远是线程安全的

这里以一个Servlet为例, 说明什么叫做无状态:

因为Servlet对象不包含属性, 也没有引用其他类的属性, 只有本地变量. 本地变量唯一的存储在本线程的栈中, 只有执行线程(本Servlet)才能访问, 所以是线程安全的.

使用原子操作

  • 读-改-写 不是原子的, 例如count++
  • check-then-run, 不是原子的, 例如: if (i != null) i = new SomeClass();

无状态+单个内置原子变量=线程安全, 但是两个及以上的原子变量!=线程安全

为了维持状态一致性, 必须将相关的状态变量在同一个原子操作内更新

注意

并不是说将一个类的所有方法都标注成synchronized, 然后这个类就是线程安全的了, 举个例子, 如果两个属性之间需要同步更新, 然后分别提供了setter方法, 那么在类外使用的时候, 就有可能引发安全问题.