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

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

  • 介绍几种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显然没有逃出createString()函数之外。以此为基础,虚拟机才可以大胆地将v内部的 加锁操作去除。如果createStrings()放回的不是String数组,而是v本身,那么就认为变量v逃逸出了当前函数,也就是说v有可能被其他线程访问。如果是这样,虚拟机就不能消除v中的锁操作。
  • 逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。
  • 使用-XX:+EliminateLocks参数可以打开锁消除。

4.3 人手一支笔:ThreadLocal

  • 除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。
  • 如果说锁是使用第一种思路,那么ThreadLocal就是使用第二种思路了。

4.3.1 ThreadLocal的简单使用

  • 从ThreadLocal的名字上可以看到,这是一个线程的局部变量。也就是说,只有当前线程可以访问。既然是只有当前线程可以访问的数据,自然是线程安全的。
  • 下面来看一个简单的实例:
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static class ParseDate implements Runnable {
    int i = 0;
    public ParseDate(int i) {this.i = i;}
    public void run() {
        try {
            Date t = sdf.parse("2015-03-29 19:29:" + i % 60);
            System.out.println(i + ":" + t);
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}

public static void main(String[] args) {
    ExecutorService es = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 1000; i++) {
        es.execute(new ParseDate(i));
    }
}
  • 上述代码在多线程中使用SimpleDateFormat来解析字符串类型的日期。如果你执行上述的代码,一般来说,你很可能得到一些异常:

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

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

static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();
public static class ParseDate implements Runnable {
    int i = 0;
    public ParseDate(int i) {this.i = i;}
    public vid run() {
        try {
            if (tl.get() == null) {
                tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
            }
            Date t = tl.get().parse("2015-03-29 19:29:" + i % 60);
            System.out.println(i + ":" + t);
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}
  • 上述代码第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对应的所有线程的局部变量都有可能被回收。先来看一个简单的例子:
public class ThreadLocalDemo_Gc {
    static volatile ThreadLocla<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>() {
        protected void finalize() throws Throwable {
            System.out.println(this.toString() + " is gc");
        }
    };
    static volatile CountDownLatch cd = new CountDownLatch(1000);
    public static class ParseDate implements Runnable {
        int i = 0;
        public ParseDate(int i) {
            this.i = i;
        }
        
        public void run() {
            try {
                if (tl.get() == null) {
                    tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") {
                        protected void finalize() throws Throwable {
                            System.out.println(this.toString() +" is gc");
                        }
                    });
                System.out.println(Thread.currentThread().getId() + ": create SimpleDateFormat");    
                    
                }
                Date t = tl.get().parse("2015-03-29 19:29:" + i % 60);
            } catch (ParseException e) {
                e.printStackTrace();
            } finally {
                cd.countDown();
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10000; i++) {
            es.execute(new ParseDate(i));
        }
        cd.await();
        System.out.println("mission complete!!");
        tl = null;
        System.gc();
        System.out.println("first GC complete!!");
        //在设置ThreadLocal的时候,会清除ThreadLocalMap中的无效对象
        tl = new ThreadLocal<SimpleDateFormat>();
        cd = new CountDownLatch(10000);
        for (int i = 0; i < 10000; i++) {
            es.execute(new ParseDate(i));
        }
        cd.await();
        Thread.sleep(1000);
        System.gc();
        System.out.println("second GC complete!!");
    }
}
  • 上述案例是为了跟踪ThreadLocal对象以及内部SimpleDateFormat对象的垃圾回收。为此,我们在第3行和第17行,重载了finalize()方法。这样,我们在对象被回收时,就可以看到它们的踪迹。

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

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

  • 注意这些输出的所代表的含义。首先,线程池10个线程都各自创建了一个SimpleDateFormat对象实例。接着进行第一次GC,可以看到ThreadLocal对象被回收了(这里使用了匿名类,所以类名看起来有 点怪,这个类就是第2行创建的tl对象)。接着提交了第2次任务,这次一样也创建10个SimpleDateFormat对象。然后,进行第2次GC。可以看到,在第2次GC后,第一次创建的10个SimpleDateFormat子类实例全部被回收。可以看到,虽然我们没有手工remove()这些对象,但是系统依然有可能回收它们。

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

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

static class Entry extends WeakReference<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,但是实际上,它并不真的持有TheadLocal的引用。而当ThreadLocal的外部强引用被回收时,ThreadLocalMap中的key就会变成null。当系统进行ThreadLocalMap清理时(比如将新的变量加入表中,就会自 动进行一次清理),就会自然将这些垃圾数据回收。这个结构如图4.1所示。

4.3.3 对性能有何帮助

  • 为每一个线程分配一个独立的对象对系统性能也许是有帮助的。这也不一定,这完全取决于共享对象的内部逻辑。如果共享对象对于竞争的处理容易引起性能损失,我们还是应该考虑ThreadLocal为每 个线程分配单独的对象。一个典型的案例就是在多线程下产生随机数。
  • 这里,让我们简单测试一下在多线程下产生随机数的性能问题。首先,我们定义一些全局变量:
public static final int GEN_COUNT = 10000000;
public static final int THREAD_COUNT = 4;
static ExecutorService exe = Executors.newFixedThreadPool(THREAD_COUNT);
public static Random rnd = new Random(123);

public static ThreadLocal<Random> tRnd = new ThreadLocal<Random>() {
    @Override
    protected Random initialValue() {
        return new Random(123);
    }
};
  • 代码第1行定义了每个线程要产生的随机数数量,第2行定了参与工作的线程数量,第3行定义了线程池,第4行定义了被多线程共享的Random实例用于产生随机数,第6~11行定义了由ThreadLocal封装的Random。
  • 接着,定义一个工作线程的内部逻辑。它可以工作在两种模式下:
  • 第一是多线程共享一个Random(mode = 0),
  • 第二是多个线程各分配一个Random(mode = 1)。
public static class RndTask implements Callable<Long> {
    private int mode = 0;
    public RndTask(int mode) {
        this.mode = mode;
    }
    public Random getRandom() {
        if (mode == 0) {
            return rnd
        } else if (mode == 1) {
            return tRnd.get();
        } else {
            return null;
        }
    }
    
    @Override
    public Long call() {
        long b = System.currentTimeMillis();
        for (long i = 0; i < GEN_COUNT; i++) {
            getRandom().nextInt();
        }
        long e = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() +"spend " + (e - b) + "ms");
        return e - b;
    }
}
  • 上述代码第19-27行定义了线程的工作内容。每个线程会产生若干个随机数,完成工作后,记录并返回所消耗的世界。
  • 最后是我们的main()函数,它分别对上述两种情况进行测试,并打印了测试的耗时:
public static void main(String[] args) throws InterruptedException, ExecutionException {
    Future<long>[] futs = new Future[THREAD_COUNT];
    for (int i = 0; i < THREAD_COUNT; i++) {
        futs[i] = exe.submit(new RndTask(0));
    }
    long totaltime = 0;
    for (int i = 0; i < THREAD_COUNT; i++) {
        totaltime += futs[i].get();
    }
    System.out.println("多线程访问同一个Random实例:" + totaltime + "ms");
    
    //ThreadLocal的情况
    for (int i = 0; i < THREAD_COUNT; i++) {
        futs[i] = exe.submit(new RndTaks(1));
    }
    
    totaltime = 0;
    for (int i = 0; i < THREAD_COUNT; i++) {
        totaltime += futs[i].get()''
    }
    System.out.println("使用ThreadLocal包装Random实例:" + totaltime + "ms");
    exe.shutdown();
}

posted @ 2018-01-23 00:14  _sanjun  阅读(130)  评论(0编辑  收藏  举报