返回介绍

4.3 人手一支笔:ThreadLocal

发布于 2024-08-21 22:20:21 字数 11880 浏览 0 评论 0 收藏 0

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。比如,让100个人填写个人信息表,如果只有一支笔,那么大家就得挨个填写,对于管理人员来说,必须保证大家不会去哄抢这仅存的一支笔,否则,谁也填不完。从另外一个角度出发,我们可以干脆就准备100支笔,人手一支,那么所有人都可以各自为营,很快就能完成表格的填写工作。

如果说锁是使用第一种思路,那么ThreadLocal就是使用第二种思路了。

4.3.1 ThreadLocal的简单使用

从ThreadLocal的名字上可以看到,这是一个线程的局部变量。也就是说,只有当前线程可以访问。既然是只有当前线程可以访问的数据,自然是线程安全的。

下面来看一个简单的示例:

01 private static final  SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
02 public static class ParseDate implements Runnable{
03   int i=0;
04   public ParseDate(int i){this.i=i;}
05   public void run() {
06     try {
07       Date t=sdf.parse("2015-03-29 19:29:"+i%60);
08       System.out.println(i+":"+t);
09     } catch (ParseException e) {
10       e.printStackTrace();
11     }
12   }
13 }
14 public static void main(String[] args) {
15   ExecutorService es=Executors.newFixedThreadPool(10);
16   for(int i=0;i<1000;i++){
17     es.execute(new ParseDate(i));
18   }
19 }

上述代码在多线程中使用SimpleDateFormat来解析字符串类型的日期。如果你执行上述代码,一般来说,你很可能得到一些异常(篇幅有限不再给出堆栈,只给出异常名称):

Exception in thread "pool-1-thread-26" java.lang.NumberFormatException: For input string: ""
Exception in thread "pool-1-thread-17" java.lang.NumberFormatException: multiple points

出现这些问题的原因,是SimipleDateFormat.parse()方法并不是线程安全的。因此,在线程池中共享这个对象必然导致错误。

一种可行的方案是在sdf.parse()前后加锁,这也是我们一般的处理思路。这里我们不这么做,我们使用ThreadLocal为每一个线程都产生一个SimpleDateformat对象实例:

01 static ThreadLocal<SimpleDateFormat> tl=new ThreadLocal<SimpleDateFormat>();
02 public static class ParseDate implements Runnable{
03   int i=0;
04   public ParseDate(int i){this.i=i;}
05   public void run() {
06     try {
07       if(tl.get()==null){
08         tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
09       }
10       Date t=tl.get().parse("2015-03-29 19:29:"+i%60);
11       System.out.println(i+":"+t);
12     } catch (ParseException e) {
13       e.printStackTrace();
14     }
15   }
16 }

上述代码第7~9行,如果当前线程不持有SimpleDateformat对象实例。那么就新建一个并把它设置到当前线程中,如果已经持有,则直接使用。

从这里也可以看到,为每一个线程人手分配一个对象的工作并不是由ThreadLocal来完成的,而是需要在应用层面保证的。如果在应用上为每一个线程分配了相同的对象实例,那么ThreadLocal也不能保证线程安全。这点也需要大家注意。

注意:为每一个线程分配不同的对象,需要在应用层面保证。ThreadLocal只是起到了简单的容器作用。

4.3.2 ThreadLocal的实现原理

那ThreadLocal又是如何保证这些对象只被当前线程所访问呢?下面让我们一起深入ThreadLocal的内部实现。

我们需要关注的,自然是ThreadLocal的set()方法和get()方法。从set()方法先说起:

public void set(T value) {
  Thread t = Thread.currentThread();
  ThreadLocalMap map = getMap(t);
  if (map != null)
    map.set(this, value);
  else
    createMap(t, value);
}

在set时,首先获得当前线程对象,然后通过getMap()拿到线程的ThreadLocalMap,并将值设入ThreadLocalMap中。而ThreadLocalMap可以理解为一个Map(虽然不是,但是你可以把它简单地理解成HashMap),但是它是定义在Thread内部的成员。注意下面的定义是从Thread类中摘出来的:

ThreadLocal.ThreadLocalMap threadLocals = null;

而设置到ThreadLocal中的数据,也正是写入了threadLocals这个Map。其中,key为ThreadLocal当前对象,value就是我们需要的值。而threadLocals本身就保存了当前自己所在线程的所有“局部变量”,也就是一个ThreadLocal变量的集合。

在进行get()操作时,自然就是将这个Map中的数据拿出来:

public T get() {
  Thread t = Thread.currentThread();
  ThreadLocalMap map = getMap(t);
  if (map != null) {
    ThreadLocalMap.Entry e = map.getEntry(this);
    if (e != null)
      return (T)e.value;
  }
  return setInitialValue();
}

首先,get()方法也是先取得当前线程的ThreadLocalMap对象。然后,通过将自己作为key取得内部的实际数据。

在了解了ThreadLocal的内部实现后,我们自然会引出一个问题。那就是这些变量是维护在Thread类内部的(ThreadLocalMap定义所在类),这也意味着只要线程不退出,对象的引用将一直存在。

当线程退出时,Thread类会进行一些清理工作,其中就包括清理ThreadLocalMap,注意下述代码的加粗部分:

  /**
   * 在线程退出前,由系统回调,进行资源清理
   */
  private void exit() {
    if (group != null) {
      group.threadTerminated(this);
      group = null;
    }
    target = null;
    /* 加速资源清理 */
    threadLocals = null;
    inheritableThreadLocals = null;
    inheritedAccessControlContext = null;
    blocker = null;
    uncaughtExceptionHandler = null;
  }

因此,如果我们使用线程池,那就意味着当前线程未必会退出(比如固定大小的线程池,线程总是存在)。如果这样,将一些大大的对象设置到ThreadLocal中(它实际保存在线程持有的threadLocals Map内),可能会使系统出现内存泄露的可能(这里我的意思是:你设置了对象到ThreadLocal中,但是不清理它,在你使用几次后,这个对象也不再有用了,但是它却无法被回收)。

此时,如果你希望及时回收对象,最好使用ThreadLocal.remove()方法将这个变量移除。就像我们习惯性地关闭数据库连接一样。如果你确实不需要这个对象了,那么就应该告诉虚拟机,请把它回收掉,防止内存泄露。

另外一种有趣的情况是JDK也可能允许你像释放普通变量一样释放ThreadLocal。比如,我们有时候为了加速垃圾回收,会特意写出类似obj=null之类的代码。如果这么做,obj所指向的对象就会更容易地被垃圾回收器发现,从而加速回收。

同理,如果对于ThreadLocal的变量,我们也手动将其设置为null,比如tl=null。那么这个ThreadLocal对应的所有线程的局部变量都有可能被回收。这里面的奥秘是什么呢?先来看一个简单的例子:

01 public class ThreadLocalDemo_Gc {
02  static volatile ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>() {
03     protected void finalize() throws Throwable {
04       System.out.println(this.toString() + " is gc");
05     }
06   };
07   static volatile CountDownLatch cd = new CountDownLatch(10000);
08   public static class ParseDate implements Runnable {
09     int i = 0;
10     public ParseDate(int i) {
11       this.i = i;
12     }
13     public void run() {
14       try {
15         if (tl.get() == null) {
16           tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") {
17             protected void finalize() throws Throwable {
18               System.out.println(this.toString() + " is gc");
19             }
20           });
21       System.out.println(Thread.currentThread().getId() + ":create SimpleDateFormat");
22         }
23         Date t = tl.get().parse("2015-03-29 19:29:" + i % 60);
24       } catch (ParseException e) {
25         e.printStackTrace();
26       } finally {
27         cd.countDown();
28       }
29     }
30   }
31
32   public static void main(String[] args) throws InterruptedException {
33     ExecutorService es = Executors.newFixedThreadPool(10);
34     for (int i = 0; i < 10000; i++) {
35       es.execute(new ParseDate(i));
36     }
37     cd.await();
38     System.out.println("mission complete!!");
39     tl = null;
40     System.gc();
41     System.out.println("first GC complete!!");
42     //在设置ThreadLocal的时候,会清除ThreadLocalMap中的无效对象
43     tl = new ThreadLocal<SimpleDateFormat>();
44     cd = new CountDownLatch(10000);
45     for (int i = 0; i < 10000; i++) {
46       es.execute(new ParseDate(i));
47     }
48     cd.await();
49     Thread.sleep(1000);
50     System.gc();
51     System.out.println("second GC complete!!");
52   }
53 }

上述案例是为了跟踪ThreadLocal对象以及内部SimpleDateFormat对象的垃圾回收。为此,我们在第3行和第17行,重载了finalize()方法。这样,我们在对象被回收时,就可以看到它们的踪迹。

在主函数main中,先后进行了两次任务提交,每次10000个任务。在第一次任务提交后,代码第39行,我们将tl设置为null,接着进行一次GC。接着,我们进行第2次任务提交,完成后,在第50行再进行一次GC。

如果你执行上述代码,则最有可能的一种输出如下:

10:create SimpleDateFormat
11:create SimpleDateFormat
13:create SimpleDateFormat
17:create SimpleDateFormat
14:create SimpleDateFormat
8:create SimpleDateFormat
16:create SimpleDateFormat
15:create SimpleDateFormat
12:create SimpleDateFormat
9:create SimpleDateFormat
mission complete!!
first GC complete!!
geym.conc.ch4.tl.ThreadLocalDemo_Gc$1@15f157b is gc
9:create SimpleDateFormat
8:create SimpleDateFormat
16:create SimpleDateFormat
13:create SimpleDateFormat
15:create SimpleDateFormat
10:create SimpleDateFormat
11:create SimpleDateFormat
14:create SimpleDateFormat
17:create SimpleDateFormat
12:create SimpleDateFormat
second GC complete!!
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc
geym.conc.ch4.tl.ThreadLocalDemo_Gc$ParseDate$1@4f76f1a0 is gc

注意这些输出所代表的含义。首先,线程池中10个线程都各自创建了一个SimpleDateFormat对象实例。接着进行第一次GC,可以看到ThreadLocal对象被回收了(这里使用了匿名类,所以类名看起来有点怪,这个类就是第2行创建的tl对象)。接着提交了第2次任务,这次一样也创建了10个SimpleDateFormat对象。然后,进行第2次GC。可以看到,在第2次GC后,第一次创建的10个SimpleDateFormat子类实例全部被回收。可以看到,虽然我们没有手工remove()这些对象,但是系统依然有可能回收它们(注意,这段代码是在JDK 7中输出的,在JDK 8中,你也许得不到类似的输出,大家可以比较两个JDK版本之间线程持有ThreadLocal变量的不同)。

要了解这里的回收机制,我们需要更进一步了解Thread.ThreadLocalMap的实现。之前我们说过,ThreadLocalMap是一个类似HashMap的东西。更精确地说,它更加类似WeakHashMap。

ThreadLocalMap的实现使用了弱引用。弱引用是比强引用弱得多的引用。Java虚拟机在垃圾回收时,如果发现弱引用,就会立即回收。ThreadLocalMap内部由一系列Entry构成,每一个Entry都是WeakReference<ThreadLocal>:

static class Entry extends WeakReference<ThreadLocal> {
  /** The value associated with this ThreadLocal. */
  Object value;
  Entry(ThreadLocal k, Object v) {
    super(k);
    value = v;
  }
}

这里的参数k就是Map的key,v就是Map的value。其中k也就是ThreadLocal实例,作为弱引用使用(super(k)就是调用了WeakReference的构造函数)。因此,虽然这里使用ThreadLocal作为Map的key,但是实际上,它并不真的持有ThreadLocal的引用。而当ThreadLocal的外部强引用被回收时,ThreadLocalMap中的key就会变成null。当系统进行ThreadLocalMap清理时(比如将新的变量加入表中,就会自动进行一次清理,虽然JDK不一定会进行一次彻底的扫描,但显然在我们这个案例中,它奏效了),就会自然将这些垃圾数据回收。整个结构如图4.1所示。

图4.1 ThreadLocal的回收机制

4.3.3 对性能有何帮助

为每一个线程分配一个独立的对象对系统性能也许是有帮助的。当然了,这也不一定,这完全取决于共享对象的内部逻辑。如果共享对象对于竞争的处理容易引起性能损失,我们还是应该考虑使用ThreadLocal为每个线程分配单独的对象。一个典型的案例就是在多线程下产生随机数。

这里,让我们简单测试一下在多线程下产生随机数的性能问题。首先,我们定义一些全局变量:

01 public static final int GEN_COUNT = 10000000;
02 public static final int THREAD_COUNT = 4;
03 static ExecutorService exe = Executors.newFixedThreadPool(THREAD_COUNT);
04 public static Random rnd = new Random(123);
05
06 public static ThreadLocal<Random> tRnd = new ThreadLocal<Random>() {
07   @Override
08   protected Random initialValue() {
09     return new Random(123);
10   }
11 };

代码第1行定义了每个线程要产生的随机数数量,第2行定义了参与工作的线程数量,第3行定义了线程池,第4行定义了被多线程共享的Random实例用于产生随机数,第6~11行定义了由ThreadLocal封装的Random。

接着,定义一个工作线程的内部逻辑。它可以工作在两种模式下:

第一是多线程共享一个Random(mode=0),

第二是多个线程各分配一个Random(mode=1)。

01 public static class RndTask implements Callable<Long> {
02   private int mode = 0;
03
04   public RndTask(int mode) {
05     this.mode = mode;
06   }
07
08   public Random getRandom() {
09     if (mode == 0) {
10       return rnd;
11     } else if (mode == 1) {
12       return tRnd.get();
13     } else {
14       return null;
15     }
16   }
17
18   @Override
19   public Long call() {
20     long b = System.currentTimeMillis();
21     for (long i = 0; i < GEN_COUNT; i++) {
22       getRandom().nextInt();
23     }
24     long e = System.currentTimeMillis();
25     System.out.println(Thread.currentThread().getName() + " spend " + (e - b) + "ms");
26     return e - b;
27   }
28 }

上述代码第19~27行定义了线程的工作内容。每个线程会产生若干个随机数,完成工作后,记录并返回所消耗的时间。

最后是我们的main()函数,它分别对上述两种情况进行测试,并打印了测试的耗时:

01 public static void main(String[] args) throws InterruptedException, ExecutionException {
02   Future<Long>[] futs = new Future[THREAD_COUNT];
03   for (int i = 0; i < THREAD_COUNT; i++) {
04     futs[i] = exe.submit(new RndTask(0));
05   }
06   long totaltime = 0;
07   for (int i = 0; i < THREAD_COUNT; i++) {
08     totaltime += futs[i].get();
09   }
10   System.out.println("多线程访问同一个Random实例:" + totaltime + "ms");
11
12   //ThreadLocal的情况
13   for (int i = 0; i < THREAD_COUNT; i++) {
14     futs[i] = exe.submit(new RndTask(1));
15   }
16   totaltime = 0;
17   for (int i = 0; i < THREAD_COUNT; i++) {
18     totaltime += futs[i].get();
19   }
20   System.out.println("使用ThreadLocal包装Random实例:" + totaltime + "ms");
21   exe.shutdown();
22 }

上述代码的运行结果,可能如下:

pool-1-thread-3 spend 3398ms
pool-1-thread-1 spend 3436ms
pool-1-thread-2 spend 3495ms
pool-1-thread-4 spend 3513ms
多线程访问同一个Random实例:13842ms
pool-1-thread-4 spend 375ms
pool-1-thread-1 spend 429ms
pool-1-thread-2 spend 453ms
pool-1-thread-3 spend 499ms
使用ThreadLocal包装Random实例:1756ms

很明显,在多线程共享一个Random实例的情况下,总耗时达13秒之多(这里是指4个线程的耗时总和,不是程序执行的经历时间)。而在ThreadLocal模式下,仅耗时1.7秒左右。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文