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另辟蹊径采用了“以空间换时间”的方式来实现了数据的隔离。
前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,686评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,668评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,160评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,736评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,847评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,043评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,129评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,872评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,318评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,645评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,777评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,861评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,589评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,687评论 2 351

推荐阅读更多精彩内容