要编写线程安全的代码,其核心在于对状态访问操作进行管理,特别是对共享的和可变的状态进行访问。
共享变量是指可由多个线程同时访问,而可变的意味着其值可在其在生命周期内可发生变化。
一个对象是否安全,取决于它是否被多个线程访问,这是指访问对象的方式,而不是指访问对象的功能。需要采用同步机制来协同对对象可变状态的访问。
当多个线程同时访问某个状态变量,并且其中有一个线程执行写的操作,必须有机制来协调对这个状态变量的访问。java提供synchronized,它提供了一种独特的加锁方式,但“同步”这个术语还包括volatile类型变量,显示锁以及原子变量。
最好在设计类的时候考虑并发访问的情况,不然后面修改将较为费力。
在一些大型程序中,要找出多个线程在那些位置上将访问同一变量是十分复杂的。幸运的是,面向对象技术不仅有助于编写结构优雅、可维护性高的类,类还在一定程度上缩小了查找被同步访问状态的范围,有利于助于编写线程安全的代码。java并没有强制要求把被同步访问状态都放进类中,可以放进一个某个公开的区域,只要我们知道存放在哪就可以对同步访问它进行很好的控制,总之程序的状态封装的越好(状态变量要尽量紧凑,这样好控制),越容易实现线程安全性。
什么是线程安全性
线程安全性指的是:当多个线程访问类的时候,类(在良好的定义中通常会定义不变性条件来约束对象的状态,以及定义各种后验证条件来描述对象的操作结果)始终都能表现出正确的行为。
我们看一个基于servlet的因数分解服务,并逐渐扩展它的功能,同时确保线程安全性。这个servlet从请求中提取值,执行因数分解,然后将结果封装到该servlet的响应中。
无状态的servlet:
@ThreadSafe
public class StatelessFactorizer implements Servlet{
public void service(ServletRequest req,ServletRespone resp){
BigInteger i=extractFromRequest(req);
BigInteger[] factors=factor(i);
encodeIntoResponse(resp,factors);
}
}
访问这个StatelessFactorizer的线程不会受另一个线程同步的影响,因为线程之间并没有共享的状态。
原子性
在无状态的类中增加一个状态时,会发生什么呢?
给上面servlet的增加计数器。
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet{
private Long count=0;
public Long getCount(){
return count;
}
public void service(ServletRequest req,ServletRespone resp){
BigInteger i=extractFromRequest(req);
BigInteger[] factors=factor(i);
++count;
encodeIntoResponse(resp,factors);
}
}
++count不是原子性 操作,可以分解为先读取count值,然后再加1,最后再把加1的值赋给count,所以这一步骤如果被多个线程同时访问那么很可能出现问题,同时读同时改同时写入,可能多个加1之后最终count只加了1。
竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。最常见的就是“先检查后执行”操作,即一个可能失效的观测结果来觉定下一步的动作。
比如上面的++count一个线程在进行写入的操作,一个线程正在读,由于还没写入成功所以读取的是写入之前的,但是“下一秒”count值已经改变了,这个读线程后面进行的操作和得到的结果都是没有意义的。
延迟初始化的竞态条件
使用“先检查后执行”的一种常见的情况就是使用延迟初始化。如下:
@NotThreadSafe
public class LazyInitRace{
private ExpensiveObject instance=null;
public ExpensiveObject getInstance(){
if(instance==null){
instance=new ExpensiveObject();
}
}
}
这样弄是不对的,反正要初始化为啥不在外面直接初始化?当多个线程第一次执行getInstance()时,A线程进行判断正在进行初始化,B线程正在判断,但是A的初始化还没结束,B线程也进行初始化,这样就是错我的。
加锁机制
加锁的目的就是为了代码路径以串行的方式访问,即对共享状态的独占访问,不会是共享资源被竞争导致阻塞或者因同步导致和异步结果不一致的情况。
内置锁
内置锁是通过synchronized关键字来实现的,synchronized方法、代码块。
- 指定当前的对象的锁
private synchronized void function() {
//TODO execute something
}
- 指定当前类的锁
private static synchronized void function() {
//TODO execute something
}
- 指定任意对象的锁
private void function() {
synchronized (object) {
//TODO execute something
}
}
这里直接把整个方法都加锁有时是不准确的,所以为了考虑性能synchronized代码块可能更合适,最后一节会提到。
重入锁
可重入锁指的是,一个线程获得它已经持有的锁,在JAVA环境下 ReentrantLock 和synchronized 都是 可重入锁,但也有不能重入的,会造成阻塞的情况,导致程序进行不下去。
可重入锁:
public class SpinLock1 {
private AtomicReference<Thread> owner =new AtomicReference<>();
private int count =0;
public void lock(){
Thread current = Thread.currentThread();
if(current==owner.get()) {
count++;
return ;
}
while(!owner.compareAndSet(null, current)){
}
}
public void unlock (){
Thread current = Thread.currentThread();
if(current==owner.get()){
if(count!=0){
count--;
}else{
owner.compareAndSet(current, null);
}
}
}
}
不可重入锁(自旋锁):
public class SpinLock {
private AtomicReference<Thread> owner =new AtomicReference<>();
public void lock(){
Thread current = Thread.currentThread();
while(!owner.compareAndSet(null, current)){
}
}
public void unlock (){
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
例子:
public class Test implements Runnable{
public synchronized void get(){
System.out.println(Thread.currentThread().getId());
set();
}
public synchronized void set(){
System.out.println(Thread.currentThread().getId());
}
@Override
public void run() {
get();
}
public static void main(String[] args) {
Test ss=new Test();
new Thread(ss).start();
new Thread(ss).start();
new Thread(ss).start();
}
public class Test implements Runnable {
ReentrantLock lock = new ReentrantLock();
public void get() {
lock.lock();
System.out.println(Thread.currentThread().getId());
set();
lock.unlock();
}
public void set() {
lock.lock();
System.out.println(Thread.currentThread().getId());
lock.unlock();
}
@Override
public void run() {
get();
}
public static void main(String[] args) {
Test ss = new Test();
new Thread(ss).start();
new Thread(ss).start();
new Thread(ss).start();
}
}
例子结果如下:
Threadid: 8
Threadid: 8
Threadid: 10
Threadid: 10
Threadid: 9
Threadid: 9
性能
多线程是为了更快的执行代码、对多个请求同时做出相应、更好的利用计算机的多核等,但是为了对“共享资源”的“监管”,引入了锁,使代码路径以串行的方式访问。
有些人就说直接在“最外面”加锁就行了,肯定不会有问题,确实不会有问题,那和不并发有啥区别呢?所以要恰当的选择加锁的位置,除了对共享资源直接或间接的操作,其他的可以不在乎线程之间的执行顺序,让线程做好“准备”,这样就能很好的提高效率。
如上面对加了计数器的service不能直接用synchronized关键字直接修饰方法,而应该如下:
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet{
private Long count=0;
public Long getCount(){
return count;
}
public void service(ServletRequest req,ServletRespone resp){
BigInteger i=extractFromRequest(req);
BigInteger[] factors=factor(i);
synchronized(this){
++count;
}
encodeIntoResponse(resp,factors);
}
}