031.[转] 从类状态看Java多线程安全并发
从类状态看Java多线程安全并发
对于Java开发人员来说,i++的并发不安全是人所共知,但是它真的有那么不安全么?
在开发Java代码时,如何能够避免多线程并发出现的安全问题?这是所有Java程序员都会面临的问题。本文讲述了在开发Java代码时安全并发设计所需要考虑的点,文中以一张图展开,围绕着Java类状态,讨论各种情况下的并发安全问题。当理解了Java类的各种变量状态在并发情况下的表现,在写Java多线程代码时就可以做到心中有数,游刃有余,写出更加安全、健壮、灵活的多线程并发代码。
目录
- 1. 多线程并发简介
- 2. 从类状态看Java安全并发
- 3. Java安全并发分解
- 3.1 无状态类
- 3.2 有状态类
- 3.3 私有状态类
- 3.4 共享状态类
- 3.5 不可变状态类(常量状态)
- 3.6 可变状态类
- 3.7 非阻塞设计
- 3.8 阻塞设计
- 3.8.1 资源死锁(resource deadlock)
- 3.8.2 锁顺序死锁(lock-ordering deadlock)
- 3.8.3 状态公开
- 4. 类的静态状态
- 5. 类外部状态和多线程安全并发
- 6. 小结
- 7. 演示代码
- 8. 参考资料
1. 多线程并发简介
在现代操作系统中,CPU的调度都是以线程为基本单位,各个线程各自独立获取到CPU时间片并执行代码指令,这就是多线程并发。于此同时,同一进程中的所有线程将共享当前进程的内存地址空间,这些线程可以访问当前内存地址空间上的同一个变量。若一个线程在使用某个变量时,另一个线程对这个变量进行修改,将造成不可预测的结果,这也是多线程并发问题。
一个简单的例子是,当一个线程循环读取一个数组时,另外一个线程对这个数组内对象进行删除,则前面一个线程可能读取失败或读取的是脏数据。
在多线程并发中,若一段代码的执行不能按预期正确地进行,或者执行的最终结果不可预测,则我们说这段代码并发不安全。换句话说,若线程之间能够按照预期执行代码,操作数据并获取到期望的结果,则实现了安全的并发。
2. 从类状态看Java安全并发
类状态是指类中所声明的变量,无论是公有变量、私有变量,亦或static和final修饰的变量,都是不同形式的类状态。按照Java语法,类变量有如下各种形式,
- 公有变量(public)、私有变量(private)、保护变量(protect)
- 静态变量(static)
- 不可变变量(final)
- 外部变量、内部变量、局部变量
这些类变量在运行时刻,映射到JVM内存中各种对象。Java安全并发设计,其核心在于如何处理这些变量在并发中的表现,掌握它们的特性是Java安全并发设计的关键。
下图从类状态出发,简要的说明了Java类变量的各种状态形式,及其相关的并发安全性,
其中,
- 绿色方块说明多线程并发安全。
- 桔红色方块说明多线程并发不安全,会出现问题。
- 图中的Java类是指完全依据面向对象设计,即:类成员变量被声明为私有,类方法只对类内部成员变量进行操作。
- 有状态是指Java类中有成员变量声明,无论是公有、私有还是保护变量,亦或static和final;无状态则指类中无任何成员变量声明。
- 私有状态是指类成员变量通过ThreadLocal进行了线程隔离,实现了按线程进行变量的分配;而共享状态则指类变量可以被多线程访问。
- 不可变状态是指类成员变量被声明为final,是一种常量状态。
- 静态状态是指类成员变量被声明为static。
- 阻塞是指线程在执行代码前,必须获取锁,这个锁只有一个,通过锁实现了代码的多线程串行执行。
需要注意的是,该图是以Java语言为例来说明如何设计并发安全的对象类,但实践中,图中所涉及的状态、私有状态、不可变状态、非阻塞和阻塞访问,这些概念也应该适用于更多面对对象的编程语言。
下面将对上图中各个类状态进行一一讲解,介绍各个状态下并发设计的要点。
3. Java安全并发分解
3.1 无状态类
一个无状态类是指其没有任何声明的成员变量,例如,
public class StatelessClass {
public void increment() {
int i = 0;
String msg = String.valueOf(i++);
log.info(msg);
}
}
无状态类是线程安全的。上述类中的increment()方法中,有两个本地变量i和msg,这两个本地变量都在方法栈空间上分配,由于栈内存空间是按线程各自独立的,相互隔离,因此栈空间上的变量是线程安全的。
由此还可以知道,在方法调用中分配的变量和对象,若在栈退出后变量或对象引用被JVM释放(不会被外部再访问到),则这个变量和对象也是线程安全的。关于本地变量和JVM栈空间的更多介绍,可以参考这篇文章。
3.2 有状态类
和无状态相反,有状态类是指类中有声明的成员变量,例如
public class StatefulClass {
private int i=0;
public void increment() {
i++;
}
}
上面的类声明了一个int i的类变量,并初始化为0。大多数情况下Java类都是属于有状态类。
有状态是导致线程不安全的必要条件,但它不是充分条件,请继续看下文。
3.3 私有状态类
若Java类的状态通过ThreadLocal等方法,使得状态被隔离在各个线程中,相互不干扰,例如,
public class PrivateStateClass {
private ThreadLocal<Integer> i = new ThreadLocal<>();
public void set(int i) {
i.set(i);
}
public void increment() {
Integer value = i.get();
i.set(value + 1);
}
}
上面的类声明了一个ThreadLocal i的变量,这个类状态按各个线程进行了隔离,为一种私有状态,在执行increment()方法时可以被多线程安全访问。
3.4 共享状态类
正常的Java成员变量是线程共享的,即多个线程通过Java类提供的类方法访问类对象时,类对象中的成员变量可以被共享访问到,这是大多数情况下的应用场景。
共享状态在多线程并发时,不一定就是不安全,其又可以分为常量状态和可变状态两种情况来讨论,请见下文。
3.5 不可变状态类(常量状态)
下面的Java类中,有一个Integer PI变量被声明为final,这说明这个变量是一个常量对象,初始化之后不再改变。
public class FinalStateClass {
private final Integer PI = 3.14;
public double calculate(double radius) {
return PI*radius*radius;
}
}
多线程访问上述的calculate()方法是线程安全的。
final声明使得变量变为常量状态,多线程在访问时不能更改状态,在一定程度上实现了只读,从而是线程安全的。
3.6 可变状态类
对于可变的共享状态,当多线程访问时,必然出现协同操作和同步问题,若代码设计不当,则很容易出现线程不安全问题。
对于可变共享状态的访问,是多线程并发设计时的考虑重点。为了实现线程安全,一般通过下面两种方法,
- 非阻塞设计(多线程并行执行,通过算法实现线程安全)
- 阻塞设计(加锁,使得多线程实现串行执行)
下面是这两种方法的简单比较,
非阻塞设计 | 阻塞设计 | |
---|---|---|
多线程执行 | 并行执行 | 串行执行 |
安全实现方法 | 通过算法设计 | 通过锁 |
吞吐性能 | 高 | 低 |
优点 | 无死锁,线程不会被阻塞挂起 | 通过锁可以实现可控的线程调度 |
缺点 | 算法实现复杂,在高度竞争情况下,吞吐性能会低于锁 | 线程的挂起和上下文切换、死锁 |
更详细的讨论见下文。
3.7 非阻塞设计
下面的Java类通过原子变量AtomicInteger实现非阻塞的自增算法。
public class AtomicStateClass {
private AtomicInteger i = new AtomicInteger(0);
public void increment() {
i.incrementAndGet();
}
}
可以看到increment()方法没有添加任何锁,但是它可以实现多线程的安全自增操作。AtomicInteger其原理是通过CAS算法,即compareAndSet()方法,先查看变量是否变化,若没有变化则设置值,若有变化,则重新尝试,在绝大数情况下,值的设置在第一次尝试就成功。
更多非阻塞算法设计,比如非阻塞的栈、非阻塞的链表插入操作,见这里。
3.8 阻塞设计
阻塞是指通过锁来控制线程对类状态的访问,使得当前状态只能由一个线程访问,其它访问线程则挂起等待,一直等到锁被释放后,所有的等待线程竞争锁,获得下一次访问权。
锁的设计,使得线程各自之间实现同步,串行执行代码指令,避免了竞争状态。但是于此同时,它也带来了死锁的困扰。若两个线程之间相互持有对方需要的资源或锁,则进入死锁状态。
JVM在解决死锁上没有提供较好的办法机制,更多的是提供监控工具来查看。对于死锁问题,最终解决方案是依赖开发者实现的代码,增加更多的资源,减少锁的碰撞,实现锁的有序持有和不定时释放,都是避免死锁的有效方案。
3.8.1 资源死锁(RESOURCE DEADLOCK)
资源死锁是一种广泛的死锁定义,简单例子是,一个打印任务需要获得打印机和文件对象,若一个线程获得了打印机,而另外一个线程获得了文件对象,相互都不释放获得的资源,则出现资源死锁情况。
增加更多的资源,是解决此类死锁的有效方案。
3.8.2 锁顺序死锁(LOCK-ORDERING DEADLOCK)
下面是一个锁顺序死锁的演示代码,
public class LockOrderingDeadLock {
public void transferMoney(Account from, Account to, Integer amount) {
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
}
}
若同时启动两个线程,分别执行下面两个操作,
- 线程1:transferMoney(accountA, accountB, 100)
- 线程2:transferMoney(accountB, accountA, 100)
则很有可能出现死锁状态,因为线程1在握有accountA对象锁的同时,线程2也握有accountB的锁。下面是对transferMoney方法测试过程中,通过JConsole观察到的死锁情况,
图1:pool-1-thread-5握有account@120f74e3的锁,等待account@3e9369b9的锁
图2:pool-1-thread-8握有account@3e9369b9的锁,等待account@120f74e3的锁
解决办法之一,是实现锁的按序持有,即对于任何两个对象锁A和B,先进行排序(排序算法必须是稳定有序),无论是哪个线程,都必须按照锁的排序,依次获取,从而避免相互持有对方需要的锁。
3.8.3 状态公开
状态公开是指类成员变量被公开,在一定程度上破坏了面向对象设计的数据封装性。对类方法再好的阻塞设计,一旦状态被公开,其并发安全性都会功亏一篑。
见下面的例子,类中定义了一个personList的对象,方法insert()和iterate()通过synchronized进行了阻塞加锁,其只能运行一个线程进入类方法执行操作。
public class PublicStateClass {
public ArrayList<String> personList = new ArrayList<>();
public synchronized void insert(String person) {
personList.add(person);
}
public synchronized void iterate() {
Integer size = personList.size();
for (int i = 0; i < size; i++) {
System.out.println(personList.get(i));
}
}
}
但多线程访问insert()和iterate()方法时,并不一定线程安全,主要原因是personList被声明了公开对象,使得类之外的线程可以轻易地访问到personList变量,从而导致personList的状态不一致,在iterate整个person列表时,可能列表中的对象已被删除。
这是类状态公开导致的线程安全问题,究其原因,还要归结于没有做好类的面对对象设计,对外部没有隐藏好数据。
下面的getList方法返回也会导致同样的问题,
public class PublicStateClass {
private ArrayList<String> personList = new ArrayList<>();
public List getList() {
return personList;
}
}
对于这样的问题,推荐的做法是,成员变量声明为私有,在执行读操作时,对外克隆一份数据副本,从而保证类内部数据对象不被泄露,
public class PublicStateClass {
private ArrayList<String> personList = new ArrayList<>();
public List getList() {
return (List) personList.clone();
}
}
4. 类的静态状态
类的静态状态是指类中被static声明的成员变量,这个状态会在类初次加载时初始化,被所有的类对象所共享。Java程序员对这个static关键字应该不会陌生,其使用的场景还是非常广泛,比如一些常量数据,由于没有必要在每个Java对象中存储一份,为了节省内存空间,很多时候声明为static变量。
但static变量并发不安全,从面向对象设计来说,一旦变量声明为静态,则作用空间扩大到整个类域,若被声明为公共变量,则成为全局性的变量,static的变量声明大大破坏了类的状态封装。
为了使静态变量变得多线程并发安全,final声明是它的“咖啡伴侣”。在阿里巴巴的编码规范中,其中一条是,若是static成员变量,必须考虑是否为final。
5. 类外部状态和多线程安全并发
上文在讲并发设计时,都是针对类内部状态,即类内部成员变量被声明为私有,类方法只对类内部变量进行操作,这是一种简化的应用场景,针对的是依据完全面向对象设计的Java类。一种更常见的情况是,类方法需要对外部传入的对象进行操作。这个时候,类的并发设计则和外部状态息息相关。
例如,
public class StatelessClass {
public void iterate(List<Person> personList) {
Integer size = personList.size();
for (int i = 0; i < size; i++) {
System.out.println(personList.get(i));
}
}
}
上面的类是一个无状态类,里面没有任何声明的变量。但是iterate方法接受一个personList的列表对象,由外部传入,personList是一个外部状态。
外部状态类似上文中内部状态公开,无论在类方法上做如何的参数定义(使用ThreadLocal/final进行声明定义),做如何并发安全措施(加锁,使用非阻塞设计),类方法其对状态的操作都是不安全的。外部状态的安全性取决于外部的并发设计。
一个简单的处理方法,在调用类方法的地方,传入一个外部状态的副本,隔离内外部数据的关联性。
6. 小结
类状态的并发,本质上是内存共享数据对象的多线程访问问题。只有对代码中各个Java对象变量的状态特性掌握透彻,写起并发代码时将事倍功半。
下面的类中,整个hasPosition()方法被synchronized修饰,
public class UserLocator {
private final Map<String, String> userLocations = new HashMap<>();
public synchronized boolean hasPositioned(String name, String position) {
String key = String.format("%s.location", name);
String location = userLocations.get(key);
return location != null && position.equals(location);
}
}
但仔细查看可以知道外部变量name和position、内部变量key和location都是并发安全,只有userLocations这个变量存在并发风险,需要加锁保护。因此,将上面的方法进行如下调整,将减少锁的粒度,有效提高并发效率。
public class UserLocator {
private final Map<String, String> userLocations = new HashMap<>();
public boolean hasPositioned(String name, String position) {
String key = String.format("%s.location", name);
String location;
synchronized (this) {
location = userLocations.get(key);
}
return location != null && position.equals(location);
}
}
由此可见,了解类中各个变量特性对写好并发安全代码的重要性。在这个基础上,优化锁的作用范围,减少锁的粒度,实现锁分段,都可以做到信手拈来,游刃有余。
关于类状态,说了这么多,最后给一个全文性总结:面向对象进行类设计,隐藏好数据,控制好类的状态,从严控制变量的访问范围,能private尽量private,能final尽量final,这些都将有助于提高代码的并发健壮性。
7. 演示代码
所有的演示代码在如下的代码仓库中,
8. 参考资料
- 《Java并发编程实战》 [美] Brian Goetz 等 著,童云兰 等 译,ISBN:9787111370048。
- IBM DeveloperWorks:非阻塞算法简介