饭饭TXT > 学习管理 > 《实战Java高并发程序设计(出书版)》作者:葛一鸣/郭超【完结】 > 实战Java高并发程序设计.txt

第4章 锁的优化及注意事项

作者:葛一鸣/郭超 当前章节:16530 字 更新时间:2026-6-23 07:00

“锁”是最常用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序的性能下降。所以我们自然有必要讨论一些有关“锁”的性能问题以及相关一些注意事项。比如:避免死锁、减小锁粒度、锁分离等。

在多核时代,使用多线程可以明显地提高系统的性能。但事实上,使用多线程的方式会额外增加系统的开销。

对于单任务或者单线程的应用而言,其主要资源消耗都花在任务本身。它既不需要维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。但对于多线程应用来说,系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。

事实上,在单核CPU上,采用并行算法的效率一般要低于原始的串行算法的,其根本原因也在于此。因此,并行计算之所以能提高系统的性能,并不是因为它“少干活”了,而是因为并行计算可以更合理地进行任务调度,充分利用各个CPU资源。因此,合理的并发,才能将多核CPU的性能发挥到极致。

4.1 有助于提高“锁”性能的几点建议

“锁”的竞争必然会导致程序的整体性能下降。为了将这种副作用降到最低,我这里提出一些关于使用锁的建议,希望可以帮助大家写出性能更为优越的程序。

4.1.1 减小锁持有时间

对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间很长,那么相对地,锁的竞争程度也就越激烈。可以想象一下,如果要求100个人各自填写自己的身份信息,但是只给他们一支笔。那么如果每个人拿着笔的时间都很长,总体所花的时间就会很长。如果真的只能有一支笔共享给100个人用,那么最好就让每个人花尽量少的时间持笔,务必做到想好了再拿笔写,千万不可拿着笔才去思考这表格应该怎么填。程序开发也是类似的,应该尽可能地减少对某个锁的占有时间,以减少线程间互斥的可能。以下面的代码段为例:

public synchronized void syncMethod(){ othercode1(); mutextMethod(); othercode2(); }

syncMethod()方法中,假设只有mutextMethod()方法是有同步需要的,而othercode1()和othercode2()并不需要做同步控制。如果othercode1()和othercode2()分别是重量级的方法,则会花费较长的CPU时间。此时,如果在并发量较大,使用这种对整个方法做同步的方案,会导致等待线程大量增加。因为一个线程,在进入该方法时获得内部锁,只有在所有任务都执行完后,才会释放锁。

一个较为优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量。

public void syncMethod2(){ othercode1(); synchronized(this){ mutextMethod(); } othercode2(); }

在改进的代码中,只针对mutextMethod()方法做了同步,锁占用的时间相对较短,因此能有更高的并行度。这种技术手段在JDK的源码包中也可以很容易地找到,比如处理正则表达式的Pattern类:

public Matcher matcher(CharSequence input) { if (!compiled) { synchronized(this) { if (!compiled) compile(); } } Matcher m = new Matcher(this, input); return m; }

matcher()方法有条件地进行锁申请,只有在表达式未编译时,进行局部的加锁。这种处理方式大大提高了matcher()方法的执行效率和可靠性。

注意:减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

4.1.2 减小锁粒度

减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现。大家应该还记得这个类吧!在“3.3 JDK的并发容器”一节中,我向大家介绍了这个高性能的HashMap。但是当时我们并没有说明它的实现原理。这里,让我们更加细致地看一下这个类。

对于HashMap来说,最重要的两个方法就是get()和put()。一种最自然的想法就是对整个HashMap加锁,必然可以得到一个线程安全的对象。但是这样做,我们就认为加锁粒度太大。对于ConcurrentHashMap,它内部进一步细分了若干个小的HashMap,称之为段(SEGMENT)。默认情况下,一个ConcurrentHashMap被进一步细分为16个段。

如果需要在ConcurrentHashMap中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()操作。在多线程环境中,如果多个线程同时进行put()操作,只要被加入的表项不存放在同一个段中,则线程间便可以做到真正的并行。

由于默认有16个段,因此,如果够幸运的话,ConcurrentHashMap可以同时接受16个线程同时插入(如果都插入不同的段中),从而大大提供其吞吐量。下面代码显示了put()操作的过程。在第5~6行,根据key,获得对应的段的序号。接着在第9行,得到段,然后将数据插入给定的段中。

01 public V put(K key, V value) { 02 Segment<K,V> s; 03 if (value == null) 04 throw new NullPointerException(); 05 int hash = hash(key); 06 int j = (hash >>> segmentShift) & segmentMask; 07 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck 08 (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment 09 s = ensureSegment(j); 10 return s.put(key, hash, value, false); 11 }

但是,减少锁粒度会引入一个新的问题,即:当系统需要取得全局锁时,其消耗的资源会比较多。仍然以ConcurrentHashMap类为例,虽然其put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap全局信息时,就会需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap的size()方法,它将返回ConcurrentHashMap的有效表项的数量,即ConcurrentHashMap的全部有效表项之和。要获取这个信息需要取得所有子段的锁,因此,其size()方法的部分代码如下:

sum = 0; for (int i = 0; i < segments.length; ++i) //对所有的段加锁 segments[i].lock(); for (int i = 0; i < segments.length; ++i) //统计总数 sum += segments[i].count; for (int i = 0; i < segments.length; ++i) //释放所有的锁 segments[i].unlock();

可以看到在计算总数时,先要获得所有段的锁,然后再求和。但是,ConcurrentHashMap的size()方法并不总是这样执行,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方法。但不管这么说,在高并发场合ConcurrentHashMap的size()的性能依然要差于同步的HashMap。

因此,只有在类似于size()获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能真正意义上提高系统吞吐量。

注意:所谓减少锁粒度,就是指缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力。

4.1.3 读写分离锁来替换独占锁

在之前我们已经提过,使用读写锁ReadWriteLock可以提高系统的性能。使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。如果说上节中提到的减少锁粒度是通过分割数据结构实现的,那么,读写锁则是对系统功能点的分割。

在读多写少的场合,读写锁对系统性能是很有好处的。因为如果系统在读写数据时均只使用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能做到真正的并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性。因此,理论上讲,在大部分情况下,应该可以允许多线程同时读,读写锁正是实现了这种功能。由于我们在第3章中已经介绍了读写锁,因此这里就不再重复了。

注意:在读多写少的场合,使用读写锁可以有效提升系统的并发能力。

4.1.4 锁分离

如果将读写锁的思想做进一步的延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现(如果大家印象深刻,我们在之前已经讨论了它的近亲ArrayBlockingQueue的内部实现)。

在LinkedBlockingQueue的实现中,take()函数和put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此,两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。

如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()和put()操作就不可能真正的并发,在运行时,它们会彼此等待对方释放锁资源。在这种情况下,锁竞争会相对比较激烈,从而影响程序在高并发时的性能。

因此,在JDK的实现中,并没有采用这样的方式,取而代之的是两把不同的锁,分离了take()和put()操作。

/** Lock held by take, poll, etc */ private final ReentrantLock takeLock = new ReentrantLock(); //take()函数需要持有takeLock /** Wait queue for waiting takes */ private final Condition notEmpty = takeLock.newCondition(); /** Lock held by put, offer, etc */ private final ReentrantLock putLock = new ReentrantLock(); //put()函数需要持有putLock /** Wait queue for waiting puts */ private final Condition notFull = putLock.newCondition();

以上代码片段,定义了takeLock和putLock,它们分别在take()操作和put()操作中使用。因此,take()函数和put()函数就此相互独立,它们之间不存在锁竞争关系,只需要在take()和take()间、put()和put()间分别对takeLock和putLock进行竞争。从而,削弱了锁竞争的可能性。

函数take()的实现如下,笔者在代码中给出了详细的注释,故不在正文中做进一步说明。

public E take() throws InterruptedException { E x; int c = -1; final AtomicInteger count = this.count; final ReentrantLock takeLock = this.takeLock; takeLock.lockInterruptibly(); //不能有两个线程同时取数据 try { try { while (count.get() == 0) //如果当前没有可用数据,一直等待 notEmpty.await(); //等待,put()操作的通知 } catch (InterruptedException ie) { notEmpty.signal(); //通知其他未中断的线程 throw ie; } x = extract(); //取得第一个数据 c = count.getAndDecrement(); //数量减1,原子操作,因为会和put() //函数同时访问count。注意:变量c是 //count减1前的值 if (c > 1) notEmpty.signal(); //通知其他take()操作 } finally { takeLock.unlock(); //释放锁 } if (c == capacity) signalNotFull(); //通知put()操作,已有空余空间 return x; }

函数put()的实现如下,

public void put(E e) throws InterruptedException { if (e == null) throw new NullPointerException(); int c = -1; final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly(); //不能有两个线程同时进行put() try { try { while (count.get() == capacity) //如果队列已经满了 notFull.await(); //等待 } catch (InterruptedException ie) { notFull.signal(); //通知未中断的线程 throw ie; } insert(e); //插入数据 c = count.getAndIncrement(); //更新总数,变量c是count加1前的值 if (c + 1 < capacity) notFull.signal(); //有足够的空间,通知其他线程 } finally { putLock.unlock(); //释放锁 } if (c == 0) signalNotEmpty(); //插入成功后,通知take()操作取数据 }

通过takeLock和putLock两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。

4.1.5 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

为此,虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化。比如代码段:

public void demoMethod(){ synchronized(lock){ //do sth. } //做其他不需要的同步的工作,但能很快执行完毕 synchronized(lock){ //do sth. } }

会被整合成如下形式:

public void demoMethod(){ //整合成一次锁请求 synchronized(lock){ //do sth. //做其他不需要的同步的工作,但能很快执行完毕 } }

在开发过程中,大家也应该有意识地在合理的场合进行锁的粗化,尤其当在循环内请求锁时。以下是一个循环内请求锁的例子,在这种情况下,意味着每次循环都有申请锁和释放锁的操作。但在这种情况下,显然是没有必要的。

for(int i=0;i<CIRCLE;i++){ synchronized(lock){ } }

所以,一种更加合理的做法应该是在外层只请求一次锁:

synchronized(lock){ for(int i=0;i<CIRCLE;i++){ } }

注意:性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同。所以大家需要根据实际情况,进行权衡。

4.2 Java虚拟机对锁优化所做的努力

作为一款共用平台,JDK本身也为并发程序的性能绞尽脑汁。在JDK内部也想尽一切办法提供并发时的系统吞吐量。这里,我将向大家简单介绍几种JDK内部的“锁”优化策略。

4.2.1 锁偏向

锁偏向是一种针对加锁操作的优化手段。它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳。因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。这样偏向模式会失效,因此还不如不启用偏向锁。使用Java虚拟机参数-XX:+UseBiasedLocking可以开启偏向锁。

4.2.2 轻量级锁

如果偏向锁失败,虚拟机并不会立即挂起线程。它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很轻便,它只是简单地将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

4.2.3 自旋锁

锁膨胀后,虚拟机为了避免线程真实地在操作系统层面挂起,虚拟机还会在做最后的努力——自旋锁。由于当前线程暂时无法获得锁,但是什么时候可以获得锁是一个未知数。也许在几个CPU时钟周期后,就可以得到锁。如果这样,简单粗暴地挂起线程可能是一种得不偿失的操作。因此,系统会进行一次赌注:它会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能获得锁,才会真实地将线程在操作系统层面挂起。

4.2.4 锁消除

锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。

说到这里,细心的读者可能会产生疑问,如果不可能存在竞争,为什么程序员还要加上锁呢?这是因为在Java软件开发过程中,我们必然会使用一些JDK的内置API,比如StringBuffer、Vector等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用Vector。而众所周知,Vector内部使用了synchronized请求锁。比如下面的代码:

public String[] createStrings(){ Vector<String> v=new Vector<String>(); for(int i=0;i<100;i++){ v.add(Integer.toString(i)); } return v.toArray(new String[]{}); }

注意上述代码中的Vector,由于变量v只在createStrings()函数中使用,因此,它只是一个单纯的局部变量。局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问。所以,在这种情况下,Vector内部所有加锁同步都是没有必要的。如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

锁消除涉及的一项关键技术为逃逸分析。所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。在本例中,变量v显然没有逃出createStrings()函数之外。以次为基础,虚拟机才可以大胆地将v内部的加锁操作去除。如果createStrings()返回的不是String数组,而是v本身,那么就认为变量v逃逸出了当前函数,也就是说v有可能被其他线程访问。如果是这样,虚拟机就不能消除v中的锁操作。

逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks参数可以打开锁消除。

4.3 人手一支笔:ThreadLocal

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。比如,让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

目录
设置
设置
阅读主题
字体风格
雅黑 宋体 楷书 卡通
字体大小
适中 偏大 超大
保存设置
恢复默认
手机
手机阅读
扫码获取链接,使用浏览器打开
书架同步,随时随地,手机阅读
首 页 < 上一章 章节列表 下一章 > 尾 页