6.保障线程安全的设计技术

一.Java运行时存储空间

 Java运行时空间(Java Runtime)空间可以分为堆(Heap)空间、非堆(Non-Heap)空间和栈(Stack)空间。

  • 堆空间和非堆空间是可以被多个线程共享的,而栈空间则是线程的私有空间;
  • 每个线程都有其栈空间,并且一个线程无法访问其他线程的栈空间。

 堆空间(Heap space)

  • 是在Java虚拟机启动的时候分配的一段可以动态扩容内存空间
  • 用于存储对象,即创建一个类实例时该实例所需的存储空间是在堆空间中进行分配的。
  • 线程之间的共享存储空间

 非堆空间(Non-Heap Space)

  • 也是在Java虚拟机启动的时候分配的一段可以动态扩容内存空间
  • 用于存储类的元数据,包括类的静态变量、类有哪些方法以及这些方法的元数据(名称、参数和返回值等)。
  • 是线程之间的共享存储空间。
  • 仅存储变量的值本身,如果是引用型变量,则引用型变量所引用的对象仍然存储在堆空间中。

 栈空间(Stack space)

  • 是在线程创建时,为线程的执行而准备的一段固定大小的内存空间,每个线程都有其栈空间。
  • 用于存储局部变量的变量值。线程执行(调用)一个方法前,Java虚拟机会在该线程的栈空间中为这个方法调用创建一个栈帧(Frame)。栈帧用于存储相应方法的局部变量、返回值等私有数据。对于引用类型的局部变量,栈帧中存储的是相应对象的内存地址而不是对象本身。
  • 栈空间不是线程之间共享的,一个线程无法访问另一个线程的栈空间。

 Java虚拟机运行如下代码所涉及的运行时空间如图1所示(图中箭头表示引用型变量对相应对象的引用关系)。

public class JavaMemory {

    public static void main(String[] args) {
        String msg = args.length > 0 ? args[0] : null;
        ObjectX objectX = new ObjectX();
        objectX.greet(msg);
    }
}

class ObjectX implements Serializable {
    private static final long serialVersionUID = 8554375271108416940L;

// 静态变量
private static AtomicInteger ID_Generator = new AtomicInteger(0);
// 实例变量
private Date timeCreated = new Date(); private int id; public ObjectX() { this.id = ID_Generator.getAndIncrement(); } public void greet(String message) { String msg = toString() + ":" + message; Debug.info(msg); } @Override public String toString() { return "[" + timeCreated + "] ObjectX [" + id + "]"; } }
 

               图 1 Java运行时存储空间示意图

二.不可变对象

 不可变对象(Immutable Object)是指一经创建其状态就保持不变的对象。不可变对象也具有固定的线程安全性,因此也可以被多个线程共享,而这些线程访问这些共享的对象的时候无须加锁。

  • 当不可变对象需要变化时,通过创建新的不可变对象来替换旧的不可变对象。

 一个严格意义上的不可变对象要同时满足以下所有条件:

  • 类本身使用final修饰:防止通过创建子类来改变其定义的行为。
  • 所有字段都是用final修饰:使用final修饰不仅仅是从语义上说明被修饰字段的值不可改变;更重要的是这个语义在多线程环境下保证了被修饰字段的初始化安全,即final修饰的字段在对其他线程可见时,它必定是初始化完成的。
  • 对象在初始化过程中没有逸出(Escape):防止其他类(如该类的匿名内部类)在对象初始化过程中修改其状态。
  • 任何字段,若引用了其他状态(数据)可变的对象(如数据、集合等),则这些字段必须是private修饰的,并且这些字段值不能对外暴露。若有相关方法要返回这些字段值,则应该进行防御性复制(ReadOnly)。

 不可变对象示例:

public class Candidate implements Iterable<Endpoint> {

    // 下游节点的总权重
    public final int totalWeight;

    private final Set<Endpoint> endpointSet;

    public Candidate(Set<Endpoint> endpointSet) {
        int sum = 0;
        for (Endpoint endpoint : endpointSet) {
            sum += endpoint.weight;
        }
        this.totalWeight = sum;
        this.endpointSet = endpointSet;
    }

    public int getEndpointCount() {
        return endpointSet.size();
    }

    @Override
    public Iterator<Endpoint> iterator() {
        // 防御性复制,使endpointSet只能只读
        return ReadOnlyIterator.with(endpointSet.iterator());
    }

    // 省略其他代码
}

 Candidate实例的状态包括服务节点列表(endpointSet)以及这些节点的总权重(totalWeight)。如果服务节点需要变更:

  1. 需要同时(原子操作)更新服务节点列表以及相应的总权重。为了保障这个操作的原子性,需要借助锁。
  2. 因为此Candidate是个不可变对象,因此更新操作通过创建一个新的Candidate实例来替换旧的Candidate实例来实现,但引用Candidate实例的实例变量candidate要用volatile来修饰,由此保证对实例变量candidate写操作的原子性及更新结果对于业务线程的可见性。

 有时创建严格意义上的不可变对象比较难,此时可以考虑使用等效或者近似的不可变对象(尽可能地满足不可变对象所需的条件)。

三.线程特有对象

3-1 定义

 如果多个线程需要共享同一个非线程安全对象,那么往往需要借助锁来保障线程安全。事实上,也可选择不共享非线程安全对象——对于一个非线程安全对象,每个线程都创建一个该对象的实例,各个线程仅访问各自创建的实例,且一个线程不能访问另一个线程创建的实例

 这种各个线程创建各自的实例,一个实例只能被一个线程访问的对象被称为线程特有对象(TSO, Thread Specific Object)。

 线程特有对象既保障了对非线程安全对象的访问的线程安全,又避免了锁的开销。

 对于特定类型的线程特有对象,一个线程往往只需要该对象的一个实例,这个实例可以被该线程(同一个线程)所执行的多个方法(包括不同类的方法)共享,因此线程特有对象也有利于减少对象的创建次数。

3-2 ThreadLocal<T>

 ThreadLocal<T>类(线程局部变量)相当于线程访问其线程特有对象的代理(Proxy),即各个线程通过这个代理可以创建并访问各自线程特有的对象。类型参数T指定了特有对象的类型。

 一个线程可以使用不同的ThreadLocal<T>实例来创建并访问这个线程的不同的线程特有对象实例。

 多个线程使用同一个ThreadLocal<T>实例所访问到的对象时类型T的不同实例,即这些线程各自的线程特有对象实例。

 

             图3-1.1 ThreadLocal与线程特有对象的代理关系示意图

 可理解为ThreadLocal实例(代理)中保存了一个<key为线程标识符,value为线程特有对象>的HashMap。

ThreadLocal类的常用方法
方法 功能 理解
public T get() 获取与该线程局部变量关联的当前线程的线程特有对象 hashMap.get(当前线程标识符)
public void set(T value) 重新关联该线程局部变量所对应的当前线程的线程特有对象 hashMap.put(当前线程标识符,value)
protected T initialValue() 该方法的返回值(对象)就是初始状态下该线程局部变量所对应的当前线程的线程特有对象  
public void remove() 删除该线程局部变量与相应的当前线程的线程特有对象之间的关联关系 hashMap.remove(当前线程标识符)
ThreadLocal<S> withInitial(Supplier<? extends S> supplier) 创建线程局部变量,将Lambda表达式的返回值作为初始线程特有对象  

 设threadLocal为任意一个线程局部变量。初始状态下,threadLocal并没有与之关联的线程特有对象。当一个线程初次执行threadLocal.get()时,threadLocal.get()会调用threadLocal.initialValue()。threadLocal.initialValue()的返回值就会成为threadLocal所关联的当前线程(即threadLocal.get()的执行线程)的线程特有对象。这个线程后续再次执行threadLocal.get()所返回的线程特有对象始终都是同一个对象(即保存的threadLocal.initialValue()的返回值),除非这个线程中途执行了threadLocal.set(T)。

 由于ThreadLocal的initialValue方法的返回值为null,因此在设置初始线程特有对象时需要创建ThreadLocal的子类(通常是匿名子类),并覆盖(Override)initialValue方法返回初始的线程特有对象。或者使用withInitial方法。

// 声明为静态变量
// 方式①
static final ThreadLocal<SimpleDateFormat> THREAD_LOCAL_SIMPLE_DATE_FORMAT = new ThreadLocal<SimpleDateFormat>() {
    @Override
    protected SimpleDateFormat initialValue() {
        return new SimpleDateFormat("yyyy-MM-dd");
    }
};
// 方式②
static final ThreadLocal<SimpleDateFormat> THREAD_LOCAL_SIMPLE_DATE_FORMAT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 使用
final SimpleDateFormat simpleDateFormat = THREAD_LOCAL_SIMPLE_DATE_FORMAT.get();

 线程局部变量通常被声明为某个类的静态变量。如果把线程局部变量声明为某个类的实例变量,可能导致同一个类型的线程持有对象被多次创建。而这即便不会导致错误,也会导致重复创建对象带来的浪费。

 由于线程安全的对象内部往往需要使用锁,因此,多个线程共享线程安全对象可能导致锁的争用。所以,有时为了避免锁的争用导致的开销(主要是上下文切换),特意将线程安全对象作为线程特有对象来使用,从而既避免了锁的开销,又减少了对象创建的次数。

 ThreadLocal随机验证码生成示例:

 随机生成一个6位数组成的字符串作为验证码,为了尽量保证这个验证码的随机性,使用强随机数生成器java.security.SecureRandom(随机数相关可参考:https://www.cnblogs.com/vipstone/p/14884095.html)。尽管SecureRandom是线程安全的,并因此可以被多个线程共享,但是为了避免多个线程共享SecureRandom实例可能导致的对SecureRandom内部所使用锁的争用,决定不在多个线程间共享同一SecureRandom实例。另外,考虑到每次生成验证码的时候都创建一个SecureRandom开销太大,因此决定将SecureReandom实例作为一个线程特有对象来使用。

public enum ThreadSpecificSecureRandom {

    INSTANCE;

    static final ThreadLocal<SecureRandom> SECURE_RANDOM = ThreadLocal.withInitial(() -> {
        SecureRandom secureRandom;
        try {
            secureRandom = SecureRandom.getInstance("SHA1PRNG");
        } catch (NoSuchAlgorithmException e) {
            secureRandom = new SecureRandom();
            e.printStackTrace();
        }

        // 通过以下调用来初始化种子
        secureRandom.nextBytes(new byte[20]);
        return secureRandom;
    });

    // 生成随机数
    public int nextInt(int upperBound) {
        SecureRandom secureRandom = SECURE_RANDOM.get();
        return secureRandom.nextInt();
    }

    public void setSeed(long seed) {
        SecureRandom secureRandom = SECURE_RANDOM.get();
        secureRandom.setSeed(seed);
    }
}

 ThreadSepcificSecureRandom通过线程局部变量来引用SecureRandom实例,这使得执行nextInt方法以生成验证码的多个线程各自使用各自的SecureRandom实例,从而避免了SecureRandom实例内部的锁的争用。

  • 由于SecureRandom的内部实现可能涉及多个SecureRandom实例从同一个熵池(Entropy Pool)中获取随机数生成器所需的种子(Seed),因此系统中创建的SecureRandom实例越多则熵池中的熵(Emtropy)不够用的概率就越大,当系统中的熵不够用时,那么获取熵的线程就会被阻塞。因此,在工作者线程数较大的情况下以线程特有对象的方式使用SecureRandom需要注意系统中熵的数量的有限性

 JDK1.7中引入的标准库类java.util.concurrent.ThreadLocalRandom的初衷与该案例要实现的目标类似。ThreadLocalRandom也是Random的一个子类,它相当于ThreadLocal<Random>。但ThreadLocalRandom所产生的随机数并非强随机数。

3-3 线程特有对象可能导致的问题及其规避

 使用线程特有对象可能会导致如下几个问题。

①退化与数据错乱

 由于线程和任务之间可以是一对多的关系,即一个线程可以先后执行多个任务,因此线程特有对象就相当于一个线程所执行的多个任务之间的共享对象。如果线程特有对象是一个有状态的对象且其状态会随着相应线程所执行的任务而改变,那么这个线程所执行的下一个任务可能“看到”来自前一个任务的数据,而这个数据可能与该任务并不匹配,从而导致数据错乱

 因此,在一个线程可以执行多个任务的情况下(比如生产者—消费者模式)使用线程特有对象,需要确保每个任务的处理逻辑被执行前相应线程特有对象不受前一个被执行任务的影响。

  • 在任务处理逻辑被执行前为线程局部变量重新关联一个线程特有对象(调用ThreadLocal.set(T))或者重置线程特有对象的状态来实现(HashMap.clear())。

 在线程可以可以被重复使用来执行多个任务的情况下使用线程特有对象即使不会造成数据错乱,也可能导致这种线程特有对象实际上“退化”成为任务特有对象

②ThreadLocal可能导致内存泄漏、伪内存泄漏

 

posted @ 2022-11-24 14:40  certainTao  阅读(82)  评论(0编辑  收藏  举报