注意:
更详细的内容参考<<Java并发编程实践>>
这里大量的代码都来自此书,或者在此书代码的基础上进行改动/扩展
书上的使用的是servlet上下文环境,这里改为使用Java SE实现,如有不妥,还请纠正。
本节关键字/词:
线程安全,竞争条件,Atomic(原子性),synchronized(同步锁),Reentrancy(重进入)
这里主要对重要代码分析。
代码例子引入线程安全问题
public class UnsafeSequence {
private int value;
public int getNext(){
return value++;
}
}
对于如上代码,很显然可能会出现如下情况:
A,B两个线程中对同一个UnsafeSequence对象进行操作。会有所谓的竞争条件,百度百科给出的解释是:竞争条件指多个线程或者进程在读写一个共享数据时结果依赖于它们执行的相对时间的情形
。书上给的星巴克会见朋友的例子很恰当
:点击查看
UnsafeSequence object = new UnsafeSequence();
// ...一系列的操作
// 假定现在value的值已经是9了
// 开两个线程,A,B
new Thread( ()->{ // A
object.getNext();
} ).start();
new Thread( ()->{ // B
object.getNext();
} ).start();
无状态对象永远是线程安全的
这里没有使用servlet作为例子,不过以Java SE的简单实现。
public class Stateless {
public static BigInteger service(BigInteger reqestI){
BigInteger ret = calculate(reqestI);
return ret;
}
// 这里是计算的过程,为了简化,只进行+1操作
private static BigInteger calculate(BigInteger requestI){
BigInteger ret = requestI.add(BigInteger.valueOf(1));
return ret;
}
}
看到上面。所谓无状态
,就是:它不包含域,也没有引用其它类的域;一次特定计算的瞬时状态,会唯一地存在本地(local)变量中,这些本地变量存储在线程的栈中,只有执行线程才能访问。
这里,一个访问Stateless的线程不会影响访问同一个Stateless 的其它线程的计算结果;因为两个线程不共享状态,它们如同在访问不同的实例。因为线程访问无状态对象的行为,不会影响其它线程访问该对象时的正确性,所以无状态对象是状态安全的。
原子性(Atomic)
对无状态加一个状态元素,例如添加一个类的成员变量。
public class Stateless {
private static long count = 0;
public static BigInteger service(BigInteger reqestI){
BigInteger ret = calculate(reqestI);
count++; // 这里进行状态的改变
return ret;
}
// 这里是计算的过程,为了简化,只进行+1操作
private static BigInteger calculate(BigInteger requestI){
BigInteger ret = requestI.add(BigInteger.valueOf(1));
return ret;
}
}
实际上count++为复合操作。它具体来说分三步:读取count -> count = count+1,也就是count值改变 -> 将count值写回内存。
是否有些迷惑?还好我学过汇编的知识,可以类比汇编的代码。
①先取出count所在内存的值,放到一个临时变量(类比汇编的通用寄存器)内存里
,②将临时变量的值加1
,③将临时变量内存的值赋给count的内存
。
而count++分三步,非原子操作(原子操作,一步就执行完全),在多线程编程中就存在竞争条件。
Java中有对基本变量的原子操作类的封装。例如上面的代码我们可以改写成如下所示:
public class Stateless {
private static AtomicLong count = new AtomicLong(0); //初始化原子类
public static BigInteger service(BigInteger reqestI){
BigInteger ret = calculate(reqestI);
count.incrementAndGet(); // 原子操作的封装,相当于count++;
return ret;
}
// 这里是计算的过程,为了简化,只进行+1操作
private static BigInteger calculate(BigInteger requestI){
BigInteger ret = requestI.add(BigInteger.valueOf(1));
return ret;
}
}
很容易理解。基本数据类型Long在这里被封装成类,并且对基本类型的操作,在类中都有对应的并发原子操作方法。
另外还可以看我的博文设计模式(4)—— 创建型 ——单例(Singleton),这里对单例模式的线程安全问题的处理值得我们思考。
同步锁(synchronized)
synchronized有如下两种方式:
关于syn关键字锁住的是什么:
【并发编程】【Java实现】(2)synchronized锁住什么?锁的范围?
// 第一种:
// 类似如下的格式
// synchronized方法的锁,就是该方法所在对象本身
public synchronized functionName(/**/){
/*some code*/
}
// 第二种
// 类似的格式如下
// 锁对象的引用,以及这个锁保护的代码块
synchronized(lock){
/*some code segment/block*/
}
下面考虑到我们要缓存上一次的计算结果
,减轻重复计算的次数。下面是上面代码的改进:
public class CachingCalculate {
// 上次请求的数字
private final static AtomicReference<BigInteger> lastRequestI = new AtomicReference<BigInteger>();
// 上次计算的结果
private final static AtomicReference<BigInteger> lastCalculatedResult = new AtomicReference<>();
public static BigInteger service(BigInteger requestI){
BigInteger ret;
if(requestI.equals(lastRequestI.get())){
ret = lastCalculatedResult.get();
return ret;
} else {
ret = calculate(requestI);
lastRequestI.set(requestI);
lastCalculatedResult.set(ret);
return ret;
}
}
// 这里是计算的过程,为了简化,只进行+1操作
private static BigInteger calculate(BigInteger requestI){
BigInteger ret = requestI.add(BigInteger.valueOf(1));
return ret;
}
}
这里的AtomicReference
与之前的Atomic
类比,AtomicReference<T>是对一个对象的引用的原子类,它的各种操作都是原子性的。
也就是上面的如下代码段都是原子性的操作:
lastRequestI.get();
lastCalculatedResult.get();
lastRequestI.set(requestI);
lastCalculatedResult.set(ret);
虽然这些单个步骤都是原子性的,但是放到上下文中,有一个if-else流程语句。在多个线程同时都运行这段service
函数代码时,此时我们get和set的值在多个线程的影响下,产生竞争条件,极有可能get/set的值已经过期了。虽然get,set操作是原子性的,但是放眼于上下文中,它会制约其它变量的值
。
当一个不变约束涉及多个变量时,变量间不是彼此独立的:某个变量的值会制约其它几个变量的值。因此,更新一个变量的时候,要在同一原子操作中更新其它几个。
也就是说,为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量
于是我们可以将整个函数看成一个原子操作,Java中提供的关键字为synchronized
。
下面是代码的改写,我们只需要在函数前添加关键字即可:
// 注意关键字 synchronized
public synchronized static BigInteger service(BigInteger requestI){
// ...
// ...
}
当然,这样做会存在很大的性能问题:如果此函数为一个servlet服务函数,意思就是说外部的HTTP请求直接由此函数来处理,假设此函数的工作任务挺繁杂,需要查询数据库,还要进行系统I/O操作,执行完此函数需要耗费一定的时间。而如果使用synchronized关键字锁住此函数,那么也就是说,外界的用户请求必须进行排队,一个用户请求处理完,另一个请求才能够继续响应。如果这是一个大型的电商网站,许多人同时发起HTTP请求,那么用户必然会等待很久时间,多线程并发也完全无意义了。
这种情况叫做弱并发(poor concurrency)
。
可重入(Reentrancy)
线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数置为1;如果同一个线程再次请求此锁,计数将递增;每次占用的线程退出同步块,计数器的值将递减。直到计数器达到0时,锁被释放
。
一个经典的例子就是:
查看它人的博客分析:Java并发编程实践–内部锁可重进入
总结
synchronized关键字很有用
,但是正如我们前面所说的,也会导致很大的性能问题,所以我们应该将synchronized关键字锁住的事物控制在一个恰当的范围内。不能范围过大,否则会导致性能的极大损失,不符合并发的观念
。不能范围过小,范围过小可能并不能保证线程安全性,同时请求/释放锁都需要一定的系统开销。
分解synchronized代码块的原则:尽量从synchronized块中分离耗时的且不影响共享状态的操作
;同时,我们要权衡各种设计要求,如安全性(这是权衡的前提)
,简单性,性能。简单性和性能可能会彼此冲突,我们需要做一定的权衡
。