Java 并发编程基础

并发编程这种技术在早期是没有的,因为所有的程序都能够独享 CPU、内存和 IO 设备,程序一个个接着执行,谁也不干扰谁,只要程序的逻辑没有出错,得到的结果就是准确的。但是慢慢有人发现:CPU、内存、和 IO 之间速度的差异太大。大致可以描述为:CPU 一天,内存一年、IO 设备几百年,所以特别贵的 CPU 和内存在程序执行的过程大多数时候都是空闲的,这就造成了资源的极大浪费。

后来 Unix 系统提出了 CPU 分时的解决方法,这样就能多个程序在一个 CPU上并发的执行,提高 CPU 的使用效率。所以 Java 中出现的多线程编程是为了更好的利用 CPU 的资源。进程之间的数据都是独享,而线程之间的状态和数据都共享同一个进程的,所以线程之间的切换要比进程之间的切换代价更小。线程是现代操作系统进行任务调度的最小单位。

并发编程的三大模块

并发的核心功能都是来自于操作系统的延展。所以在 Java 以及其他的编程语言的并发模块中,背后都有着共同的理论模型。

在并发编程中,大致可以抽象成三个模块:分别是分工同步互斥

分工可以提升任务的执行效率,在 Java 中就是使用多个线程分别去执行不同的任务。在执行任务的过程中,线程之间不是独立存在的,线程之间需要依赖关系的,那么同步就是用来调度线程之间的协作,具体的方式就是在一个线程完成工作后通知其他的线程开始工作。多线程的编程方式破坏了编程原本的顺序执行。所以也就引入了线程安全问题,也就是正确性问题,线程的安全问题最核心的解决方案就是互斥,实现互斥的关键就是

并发问题的源头

但是凡事有利有弊,并发计算带来了很大的好处,提升了系统资源的使用率,让系统可以把一件事又快又好的干完,但是同时也引入了新的问题。

在电脑只有一颗 CPU 的时候,所有线程都直接对一个缓存就行读写,所以缓存中数据的一致性问题就很好解决了。一个线程对共享变量的修改对其他线程可见,就称之为可见性

但是在多颗 CPU 的环境下,每个 CPU 有自己的缓存,而且对其他的 CPU 是不可见的,看起的情况就如下所示:

由于现代操作系统都是通过分时来使用 CPU 的,一个正在运行的线程随时有可能被抢夺 CPU 资源,对编写 Java 程序来说,我们通常感觉 i++ 已经是一个原子操作了,但是对于 CPU 来说,完成这个操作任然需要 3 步。所以这条语句在执行的过程中就有可能被其他线程抢断,其他线程也有可能访问 i,两个线程同时对 i 进行了操作,然后导致结果不正确,这个问题就是 原子性被破坏了。

有时候编译器会调整语句的执行顺序。因为顺序的改变也会出现bug。有一个经典的例子就是使用双重检查来创建单例对象。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

首先检查单例对象是不是存在,如果不存在,就用 synchronized 锁住 Class 对象,获得锁之后,再判断一次这个单例对象存不存在,如果不存在,那就创建一个新的单例对象。

一切看起来都很完美。但是编译器这个时候要出来搞事情,本来实例化一个对象的步骤如下:

  1. 分配一块内存 M
  2. 在内存 M 上初始化 Singleton 对象
  3. 再把 M 的地址的值给 instance 变量

但是编译器这货为了提高 CPU 缓存的使用效率,把上面的的步骤变成了这样:

  1. 分配一块内存 M
  2. 把 M 的地址值给 instance 变量
  3. 最后再在内存 M 上初始化 Singleton 对象

这样会带来什么问题呢?当线程 A 走到了优化后步骤的第二步,也就是还没有初始化 Singleton 对象的时候就把 M 的地址值给了 instance。但是还没有初始化 Singleton 对象,但是这个时候被线程 B 抢占了 CPU,发现 instance 的值不是 null,就把 instance 直接返回了,但是实际上 Singleton 并没有被初始化,使用的时候就会报错了,这就是一个顺序性问题。

所以总结起来,造成并发问题有三个主要的原因:

  • 可见性
  • 原子性
  • 顺序性

Java 多线程编程

Java 线程简介

Java 线程的生命周期如下:

  • 新建(New):创建之后未启动
  • 可运行(Runnable):线程可能正在运行,也可能正在等待 CPU 时间片,发生异常后进入 Terminated 状态
  • 阻塞(Blocked):等待获取一个排它锁,获得了锁后结束次状态,发生异常后进入 Terminated 状态
  • 无限期等待(Waiting):等待其他线程唤醒,发生异常后进入 Terminated 状态
  • 限期等待(Timed Waiting):不需要其他线程唤醒,在一定时间后会被系统自动唤醒,发生异常后进入 Terminated 状态
  • 死亡(Terminated):线程执行完成或者发生异常后进入该状态

在 Java 中,使用线程的方式有三种:

  • 实现 Runnable 接口
  • 实现 Callable 接口
  • 继承 Thread 类

通常使用实现 Runnable 接口或者 Callable 接口的方式多一点。

线程在具体的使用过程中,一般不会直接去创建新的线程,而是通过 Executor 来创建线程池,线程池主要有以下三种:

  • CachedThreadPool:一个任务创建一个线程
  • FixedThreadPool: 固定的线程池大小
  • SingleThreadExecutor: 相当于大小为 1 的 FixedThreadPool

在线程执行的过程中,可以调用线程的 interrupt() 方法或者 Executor 的 shutdown() 方法来中断线程。

Java 进行并发编程

在上面我们聊到,并发编程中涉及到的领域有三类:分工、同步、和互斥。针对这三类问题,Java 中提供完备的工具来完成这些任务:

在分工和协作方面,java.util.concurrent 中提供了 CountDownLatchCyclicBarrierSemahporeFutureTaskBlockingQueueForkJoin 等工具类来帮助进行并发编程。

在多线程中,如果多个线程对一个共享的数据同时访问会带来线程安全问题,线程安全问题是并发编程下必然会出现的问题。

在 Java 中也提供了诸多工具来帮助解决线程安全问题。首先就是 Java 内存模型,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的的方法,屏蔽了各种硬件和操作系统之间的内存访问差异。

Java 内存模型具体的实施就是通过 volatilecynchronizedfinal 关键字和 Happens-Before规则。Java 内存模型解决了并发问题中的可见性和顺序性问题。

然后就是通过来解决原子性问题,第一种锁就是 JVM 提供的 synchronized,另一个就是 JDK 实现的 ReentrantLock。

使用锁主要的问题就是线程阻塞带来的性能问题,在 Java 中也提供了不需要用锁的方法,CAS、原子类、ABA 等属于非阻塞同步的方法,这类的方法都是基于冲突检测的乐观并发策略来实现的。

还有一种保证线程安全的方法就是不要使用共享数据,不涉及到共享数据,自然也就没有了线程安全的问题。具体的方法有栈封闭线程本地存储的方法。

这篇文章简单的将 Java 并发编程中涉及到的内容都总结归纳了一下,可以算作学习 Java 并发编程的指南。

(完)

©2024 Rayjun    PowerBy Hexo