Java并发解决方案

Java并发编程的缺陷

  • 上下文切换带来的CPU开销
  • 死锁
  • 物理硬件的限制
  • 软件资源的限制

为什么需要并发?

并发其实是一种解耦合的策略,这种策略帮助我们把做什么(目标)和什么时候做(时机)分开。优点是可以明显改进应用程序的吞吐量(获得更多的CPU调度时间)和结构(程序有多个部分在协同工作)。Servlet容器就是采用单实例多线程的工作模式,来处理并发问题。

误解和正解

误解:

  • 并发总能改进性能
  • 编写并发程序无序修改原有的设计
  • 在试验Web或Spring容器时不用关注并发问题

正解:

  • 编写并发程序会在代码上增加额外的开销
  • 正确的并发是非常复杂的没及时对于很简单的问题
  • 并发中的缺陷因为不易重现也不容易被发现
  • 并发往往需要对设计策略从根本上进行修改

并发编程的原则和技巧

单一职责原则

分离并发相关代码和其他代码(并发相关代码往往有自己的开发、修改、维护、调优的生命周期)。

限制数据作用域

两个线程修改对象的同一字段时可能会相互干扰,导致不可预期的行为,解决方案之一是构造临界区,但是必须限制临界区的数量。

使用数据副本

数据副本是避免共享数据的好方法,复制出来的对象只是以只读的方式对待。Java5的 java.util.concurrent 包中增加一个名为 CopyOnWriteArrayList 的类,它是 List 接口的子类型,所以你可以认为它是 ArrayList 的线程安全的版本,它使用了写时复制的方式创建数据副本进行操作来避免对共享数据并发访问而引发的问题。

Java5以前的并发编程

Java的线程模型建立在抢占式线程调度的基础上,也就是说:

  • 所有线程可以很容易的共享同一进程中的对象。
  • 能够引用这些对象的任何线程都可以修改这些对象。
  • 为了保护数据,对象可以被锁住。

Java基于线程和锁的并发过于底层,而且使用锁很多时候都是很万恶的,因为它相当于让所有的并发都变成了排队等待。

在Java 5以前,可以用synchronized关键字来实现锁的功能,它可以用在代码块和方法上,表示在执行整个代码块或方法之前线程必须取得合适的锁。对于类的非静态方法(成员方法)而言,这意味这要取得对象实例的锁,对于类的静态方法(类方法)而言,要取得类的Class对象的锁,对于同步代码块,程序员可以指定要取得的是那个对象的锁。

不管是同步代码块还是同步方法,每次只有一个线程可以进入,如果其他线程试图进入(不管是同一同步块还是不同的同步块),JVM会将它们挂起(放入到等锁池中)。这种结构在并发理论中称为临界区(critical section)。这里我们可以对Java中用synchronized实现同步和锁的功能做一个总结:

  • 只能锁定对象,不能锁定基本数据类型
  • 被锁定的对象数组中的单个对象不会被锁定
  • 同步方法可以视为包含整个方法的 synchronized(this) { … } 代码块
  • 静态同步方法会锁定它的 Class 对象
  • 内部类的同步是独立于外部类的
  • ** synchronized 修饰符并不是方法签名的组成部分,所以不能出现在接口的方法声明中**
  • 非同步的方法不关心锁的状态,它们在同步方法运行时仍然可以得以运行
  • ** synchronized 实现的锁是可重入的锁**

在JVM内部,为了提高效率,同时运行的每个线程都会有它正在处理的数据的缓存副本,当我们使用 synchronzied 进行同步的时候,真正被同步的是在不同线程中表示被锁定对象的内存块(副本数据会保持和主内存的同步,现在知道为什么要用同步这个词汇了吧),简单的说就是在同步块或同步方法执行完后,对被锁定的对象做的任何修改要在释放锁之前写回到主内存中;在进入同步块得到锁之后,被锁定对象的数据是从主内存中读出来的,持有锁的线程的数据副本一定和主内存中的数据视图是同步的。

在Java最初的版本中,就有一个叫 volatile 的关键字,它是一种简单的同步的处理机制,因为被 volatile 修饰的变量遵循以下规则:

  • 变量的值在使用之前总会从主内存中再读取出来
  • 对变量值的修改总会在完成之后写回到主内存中

使用volatile关键字可以在多线程环境下预防编译器不正确的优化假设(编译器可能会将在一个线程中值不会发生改变的变量优化成常量),但只有修改时不依赖当前状态(读取时的值)的变量才应该声明为volatile变量。

不变模式也是并发编程时可以考虑的一种设计。让对象的状态是不变的,如果希望修改对象的状态,就会创建对象的副本并将改变写入副本而不改变原来的对象,这样就不会出现状态不一致的情况,因此不变对象是线程安全的。Java中我们使用频率极高的String类就采用了这样的设计。如果对不变模式不熟悉,可以阅读阎宏博士的《Java与模式》一书的第34章。说到这里你可能也体会到final关键字的重要意义了。

Java5的并发编程

不管今后的Java向着何种方向发展或者灭忙,Java5绝对是Java发展史中一个极其重要的版本,这个版本提供的各种语言特性我们不在这里讨论,但是我们必须要感谢Doug Lea在Java 5中提供了他里程碑式的杰作 java.util.concurrent 包,它的出现让Java的并发编程有了更多的选择和更好的工作方式。Doug Lea的杰作主要包括以下内容:

  • 更好的线程安全的容器
  • 线程池和相关的工具类
  • 可选的非阻塞解决方案
  • 显示的锁和信号量机制

原子类

Java 5中的 java.util.concurrent 包下面有一个 atomic 子包,其中有几个以 Atomic 开头的类,例如 AtomicIntegerAtomicLong。它们利用了现代处理器的特性,可以用非阻塞的方式完成原子操作,代码如下所示:

1
2
3
4
5
6
7
8
9
/**
* ID序列生成器
*/
public class IdGenerator {
private final AtomicLong sequenceNumber = new AtomicLong(0);
public long next() {
return sequenceNumber.getAndIncrement();
}
}

显示锁

基于synchronized关键字的锁机制有以下问题:

  • 锁只有一种类型,而且对所有同步操作都是一样的作用
  • 锁只能在代码块或方法开始的地方获得,在结束的地方释放
  • 线程要么得到锁,要么阻塞,没有其他的可能性

Java5对锁机制进行了重构,提供了显示的锁,这样可以在以下几个方面提升锁机制:

  • 可以添加不同类型的锁,例如读取锁和写入锁
  • 可以在一个方法中加锁,在另一个方法中解锁
  • 可以使用tryLock方式尝试获得锁,如果得不到锁可以等待、回退或者干点别的事情,当然也可以在超时之后放弃操作

显示的锁都实现了 java.util.concurrent.Lock 接口,主要有两个实现类:

  • ReentrantLock - 比synchronized稍微灵活一些的重入锁
  • ReentrantReadWriteLock - 在读操作很多写操作很少时性能更好的一种重入锁。

Java并发解决方案
https://cuilan.github.io/2019/03/04/并发编程/java并发解决方案/
作者
zhang.yan
发布于
2019年3月4日
许可协议