Java并发编程实战之线程安全性

344 阅读5分钟

1. 简介

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

共享意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的同步机制主要有:

  • synchronized:独占式
  • volatile
  • 显示锁
  • 原子变量

如果当多个程序访问一个可变的状态而没有使用同步机制时,那么程序就会出现错误,有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

2. 什么是线程安全性

2.1 定义

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,这个类始终都表现出正确的行为,那么就称这个类时线程安全的。

无状态对象一定是线程安全的。

示例程序:

package com.zyz.threadSafe;

public interface Servlet {

    void service();

}

package com.zyz.threadSafe;

public class NoStateClass implements Servlet {

    public void service() {
        String name = "无状态对象";
        System.out.println(name);
    }
}

NoStateClass这个类时无状态的:它既不包含任何域,也不包含任何对其他类中域的计算。

2.2 原子性

在无状态对象中添加一个计数器。那么该类就不是线程安全的。

package com.zyz.threadSafe;

public class UnsafeClass implements Servlet {

    private long count = 0;

    public long getCount() {
        return count;
    }

    public void service() {
        count++;
        String name = "不是线程安全的";
        System.out.println(name);
    }
}

UnsafeClass不是线程安全的,假定有两个线程在没有同步的情况对同一个count进行操作,这个类很可能会丢失一些更新操作。因为count++是一个复合操作,它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。

请看字节码文件,可以很清晰的看出count的操作过程。 count操作

所以会出现在某一时刻,多个线程读取count值是一样的,这时候同时加1,count值最终只加了1次,就会丢失更新。

2.2.1 竞态条件

最常见的竞态条件类型就是"先检查后执行"的操作。

2.2.2 延迟初始化

使用"先检查后执行"的一种常见情况就是延迟初始化。目的是将对象的初始化操作推迟到实际被使用时才进行,同时确保只初始化一次。

示例:

package com.zyz.threadSafe;

public class ExpensiveObject {
}

package com.zyz.threadSafe;

public class LazyInitRace {

    private ExpensiveObject instance = null;

    public ExpensiveObject getInstance(){
        if (instance == null){
            return new ExpensiveObject();
        }
        return instance;
    }

}

在LazyInitRace包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance。如果A线程和B线程同一时间判断instance是否为空,那么这时候就会创建多个ExpensiveObject实例。

2.2.3 复合操作

LazyInitRace和UnsafeClass都包含一组需要以原子方式执行的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式阻止其他线程使用这个变量。

对于UnsafeClass中的count操作,我们使用一个现有的线程安全类来解决。

package com.zyz.threadSafe;

import java.util.concurrent.atomic.AtomicLong;

public class CountClass implements Servlet {

    private final AtomicLong count = new AtomicLong(0);

    public long getCount() {
        return count.get();
    }

    public void service() {
        count.incrementAndGet();
    }
}

2.3 加锁机制

假设我们希望在Servlet中将最近的值缓存起来,当两个连续的线程过来时,可以直接使用上一次的结果,而无需计算。要实现该策略,需要保存两个状态:最近值以及计算值。

package com.zyz.threadSafe;

import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public class UnsafeCacheCount implements Servlet {

    private final AtomicReference<Integer> lastNumber = new AtomicReference<Integer>();

    private final AtomicReference<Integer> lastFactors = new AtomicReference<Integer>();

    public void service() {
        Integer i = getCount();
        if (i.equals(lastNumber.get())){
            System.out.println(lastFactors.get());
        }else {
            //假设有计算
            
            lastNumber.set(i);
            lastFactors.set(i);
        }
    }

    private Integer getCount() {
        Random random = new Random();
        return random.nextInt(5);
    }
}

可以发现lastNumber和lastFactories的get和set操作,并不是同时执行的,有时间差,在这个时间差里,当A线程获取lastNumber的值之后,有可能被B线程更改,这是lastNumber和lastFactories里的值就不一致了。

2.3.1 内置锁

Java提供了内置锁来支持原子性:synchronized。

每个Java对象都可以用做一个实现同步的锁,这些锁被称为监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。

Java的内置锁相当于一种互斥锁。

2.3.2 重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

重入意味着获取锁的操作的粒度是“线程”,而不是“调用”。

具体实现:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。

2.4 用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。

锁保护:对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁。

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要同一个锁来保护

参考资料

  1. 《Java并发编程实战》