ThreadLocal 理解与应用 ThreadLocal 理解与应用

ThreadLocal 理解与应用

在并发编程中,我们主要考虑的问题是多个线程对于共享数据的访问,并在访问共享数据时保证线程安全。如果我们希望每个线程都有一个共享变量的副本,并且对这个副本进行读写时不影响其他的线程该如何做呢?

JDK 为我们提供了ThreadLocal类来解决线程与数据绑定的需求。如果说synchronizedvolatile关键字保证了线程间的数据共享(可见性),那么ThraedLocal类就是保证线程间的数据隔离。为什么这么说呢?

volatilesynchronized保证共享数据在不同的线程中是可见的,一个线程对共享数据的改变其他线程也能观察到,通过同步机制来保证线程安全。而ThreadLocal类则提供了另一个保证线程安全的处理思路:

每个线程持有共享变量的一个副本,并且与线程绑定,这样每个线程对这个变量的读写都在自己线程内部,对线程来说,这个变量是属于线程私有的,不会对其他线程有影响,避免出现数据不一致的情况,也就是线程间的数据是隔离的,互不相干的。

什么是线程局部变量(thread-local variable)?

线程局部变量就是为每一个使用该变量的线程都提供一个变量值的副本,该副本是线程私有的,不同的线程持有不同的副本,每个线程都可以独立的改变这个副本并且不会和其他的线程起冲突。这是 Java 中较为特殊的线程绑定机制,从而为多线程环境中常出现的并发访问问题提供了一种数据隔离机制。

如何使用 ThreadLocal

  1. 创建 ThreadLocal 实例

        public static ThreadLocal<Integer> threadLocalData = new ThreadLocal<>();
        public static void main(String[] args) {
        System.out.println(threadLocalData.get());//输出为Null
    }
    

    一般将 ThreadLocal 变量声明为公开的静态字段,方便线程对其进行访问,需要注意的是:直接使用构造方法声明 ThreadLocal 对象后,对象的初始值为 null。

  2. ThreadLocal 对象初始化值
    ThreadLocal类提供了初始化接口

    protected T initialValue()
    

    我们可以通过继承 ThreadLocal 类并复写initialValue()方法提供初始值,提供的初始值对所有线程都是可见的。

     public static ThreadLocal<Integer> threadLocalData = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return 1;
        }
    };
    
    public static void main(String[] args) {
        System.out.println(threadLocalData.get());//输出为1
    }
    
    

    实例化 ThreadLocal 对象并将其初始值设置为 1。对所有线程来讲,它们拿到的初始值都是 1。

  3. 读取 ThreadLocal 值

    get()方法用于获取当前线程的副本变量值

    public T get()
    
  4. 写入 ThreadLocal 值

    set()方法用于写入当前线程的副本变量值

    public void set(T value)
    
  5. 删除 ThreadLocal 值

    remove()方法移除当前前程的副本变量值。每次 remove()之后都会对副本变量应用一次 initialValue(),恢复副本的初始值。所以 remove()之后再 get()得到的是初始值。

    public void remove()
    
  6. example

    我们用一个简单的例子来说明这几个接口的使用方式。

     //初始化ThreadLocal对象,并将其初始值设置为1,在Java8中使用Lambda构造方式
    public static ThreadLocal<Integer> threadLocalData = ThreadLocal.withInitial(() -> 1);
    
    public static void main(String[] args) {
    
         //先使用remove()移除值,再获取值
         new Thread(() -> {
             threadLocalData.remove();
             System.out.println("remove()&get()" + threadLocalData.get());//输出remove()&get()1
         }).start();
         //直接使用get()获取值
         new Thread(() -> System.out.println("get()" + threadLocalData.get())).start();//输出get()1
         //先使用set()设置值,再获取
         new Thread(() -> {
             threadLocalData.set(2);
             System.out.println("set()&get()" + threadLocalData.get());//输出set()&get()2
         }).start();
     }
    

ThreadLocal 使用场景

什么场景适合使用ThreadLocal类呢?

ThreadLocal 主要使用在多线程多实例(并且每个线程对应一个实例状态)的对象访问,并且不想显式的为每个多线程对象以参数传递的形式来传递这个共享变量。总结起来还是比较绕口,分开来看场景应该满足以下几点:

  1. 有一个共享变量,这个共享变量在应用的全局域一般来说只有一个,也就是单例的。一般体现为使用static final修饰。
  2. 这个共享变量是持有状态的,也就是说这个共享变量自身有一个初始值,但是又被多线程访问,每个线程都会对这个共享值进行读取操作,但是又希望每个线程有自己的独立的副本值。
  3. 要满足前两个条件,可以在 Thread 对象中设置一个字段,用来存储这个线程私有的状态,但是又不会采取线程类成员变量的方式来实现,那么就使用ThreadLocal类来隐式的持有这个共享变量的副本。

我们来举个常用的场景来说明下。在 Web 开发中,经常需要对用户的请求时间进行格式化,一般服务端会生成一个当前时间对象,然后将这个时间对象格式化为字符串类型记录到日志中,而格式化方法往往又是一个工具类,所有的线程都调用这个工具类的格式化方法,就像下面这样。

import javax.annotation.concurrent.NotThreadSafe;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

@NotThreadSafe
public class ErrorDateUtils {

    private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String format(Date date) {
        return FMT.format(date);
    }

    public static Date parse(String dateStr) throws ParseException {
        return FMT.parse(dateStr);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Date now = new Date();
                String format = format(now);
                try {
                    Date parse = parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}
/**
 * 输出:
 *  ........
 * Exception in thread "Thread-98" java.lang.NumberFormatException: For input string: ""
 *  at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
 *  at java.lang.Long.parseLong(Long.java:601)
 *  ........
 *  at java.text.DateFormat.parse(DateFormat.java:364)
 *  ........
 */


由于SimpleDateFormat类本身不是线程安全的,在多线程访问的情况下产生了异常,那么我们可以自然的想到使用同步对parse()format()方法进行处理。

import javax.annotation.concurrent.ThreadSafe;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

@ThreadSafe
public class SafeButSlowDateUtils {

    private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static synchronized String format(Date date) {
        return FMT.format(date);
    }

    public static synchronized Date parse(String dateStr) throws ParseException {
        return FMT.parse(dateStr);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Date now = new Date();
                String format = format(now);
                try {
                    Date parse = parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}



这样经过同步处理后确实不会发生线程安全问题,但是在大规模并发访问时由于同步的存在每个用户发起的请求都可能存在锁竞争的情况,拖慢系统的处理速度。当然我们可以使用栈封闭,在每个线程中实例化SimpleDateFormat对象,这样就一劳永逸的解决了线程安全问题,例如:

public class SafeButNotGraceParseDateDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SimpleDateFormat formater = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                Date now = new Date();
                String format = formater.format(now);
                try {
                    Date parse = formater.parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}

嗯,看起来不错,但是这样做就不再需要DateUtils工具类了,并且这个日期格式转换器每次都要初始化,无法复用。

现在看看我们遇到的情况:

  1. 我们需要全局有一个共享的SimpleDateFormat对象(共享)
  2. 这个全局共享的SimpleDateFormat对象是持有状态的,也就是格式化的格式字符串yyyy-MM-dd HH:mm:ss,并且会对每个线程的 Date 对象应用这个格式化模式字符串进行格式化。每个线程都会对这个共享对象进行读取操作。(共享对象有状态,且线程间状态可能不一致)
  3. 我们为了满足复用性,不希望在每个线程中实例化,SimpleDateFormat对象(不希望每个线程显式持有状态)

满足了以上的条件,我们就可以认为,目前的场景非常适合使用ThreadLocal类来解决问题,共享变量SimpleDateFormat对象不变,只需要使用ThreadLocal来做线程绑定,这样每个线程都持有SimpleDateFormat对象的副本,每个线程都持有私有的状态,是独立互不干扰的。


import javax.annotation.concurrent.ThreadSafe;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

@ThreadSafe
public class SafeAndGraceDateUtils {

    private static final ThreadLocal<SimpleDateFormat> FMT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy" +
            "-MM-dd HH:mm:ss"));


    public static String format(Date date) {
        return FMT.get().format(date);
    }

    public static Date parse(String str) throws ParseException {
        return FMT.get().parse(str);
    }

    public static void main(String[] args) throws ParseException {

        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Date now1 = new Date();
                String format = format(now1);
                try {
                    Date parse = parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}


使用ThreadLocalSimpleDateFormat封装后,每个线程都有一个独立的SimpleDateFormat副本,状态隔离,这样就不会出现线程安全问题了。

ThreadLocal 在 MyBatis 中的应用

ThreadLocal 的多线程隔离数据副本的特性非常适合在管理数据库连接中应用。例如在 Mybatis 中SqlSessionManager中就使用了ThreadLocal进行 session 管理。

我们知道SqlSessionManager负责维护管理SqlSession,SqlSessionManager本身是线程安全的,但是 DefaultSqlSession 却并不是线程安全的。如果多个并发线程同时从SqlSessionManager获取到同一个SqlSession实例,由于SqlSession实例中包含了数据库操作相关的状态信息,多个并发线程同时使用一个SqlSession实例对数据库进行读写操作则会引起数据不一致错误。所以 Mybatis 选择了使用 ThreadLocal 来维护 session,对每个线程存储一个 session 副本,这样进行了数据的隔离,防止出现线程安全问题。

注意注释标明了Note that this class is not Thread-Safe.,DefaultSqlSession本身并不是线程安全的。



/**
 *
 * The default implementation for {@link SqlSession}.
 * Note that this class is not Thread-Safe.
 *
 * @author Clinton Begin
 */
public class DefaultSqlSession implements SqlSession {

  private final Configuration configuration;
  private final Executor executor;

  private final boolean autoCommit;
  private boolean dirty;
  private List<Cursor<?>> cursorList;

  public DefaultSqlSession(Configuration configuration, Executor executor, boolean autoCommit) {
    this.configuration = configuration;
    this.executor = executor;
    this.dirty = false;
    this.autoCommit = autoCommit;
  }

SqlSessionManager使用成员变量localSqlSession来维护数据库会话。



/**
 * @author Larry Meadors
 */
public class SqlSessionManager implements SqlSessionFactory, SqlSession {

  private final SqlSessionFactory sqlSessionFactory;
  private final SqlSession sqlSessionProxy;

  private final ThreadLocal<SqlSession> localSqlSession = new ThreadLocal<SqlSession>();

 // ......

    @Override
  public Connection getConnection() {
    final SqlSession sqlSession = localSqlSession.get();
    if (sqlSession == null) {
      throw new SqlSessionException("Error:  Cannot get connection.  No managed session is started.");
    }
    return sqlSession.getConnection();
  }

  @Override
  public void clearCache() {
    final SqlSession sqlSession = localSqlSession.get();
    if (sqlSession == null) {
      throw new SqlSessionException("Error:  Cannot clear the cache.  No managed session is started.");
    }
    sqlSession.clearCache();
  }

  @Override
  public void commit() {
    final SqlSession sqlSession = localSqlSession.get();
    if (sqlSession == null) {
      throw new SqlSessionException("Error:  Cannot commit.  No managed session is started.");
    }
    sqlSession.commit();
  }



在获取连接getConnection()等方法中,即使多线程访问,也是使用localSqlSession.get()来获取线程本地绑定的localSqlSession对象副本。

ThreadLocal 使用总结

概括起来说,对于多线程资源共享的问题,同步机制采用了“以时间换空间”的方式,而 ThreadLocal另辟蹊径采用了“以空间换时间”的方式来实现了数据的隔离。
前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章