锁优化的思路和方法

1 锁优化的思路和方法

一旦用到锁,就说明这是阻塞式的。这里提到的锁优化,是指在阻塞式的状况下,如何让性能不要变得太差。可是再怎么优化,通常来讲性能都会比无锁的状况差一些。java

1.1 减小锁持有时间

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

上述代码,线程在进入方法前都要先获取到锁,同时其余线程只能在外面等待。程序员

这里优化的一点在于,要减小其余线程等待的时间,因此,只在有线程安全要求的程序上加锁。数组

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

1.2 减小锁粒度

将大对象(这个对象可能会被不少线程访问),折成小对象,大大增长并行度,下降锁竞争、下降了锁的竞争,偏向锁,轻量级锁成功率才会提升。安全

最典型的减小锁粒度的案例就是ConcurrentHashMap(ConcurrentHashMap内部使用Segment数组,每一个Segment相似于Hashtable。put操做时,先定位到Segment,锁定一个Segment,执行put)。在减少锁粒度后, ConcurrentHashMap容许若干个线程同时进入。多线程

1.3 锁分离

最多见的锁分离就是读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程的安全,又提升了性能。并发

读写分离思想能够延伸,只要操做互不影响,锁就能够分离。app

好比:LinkedBlockingQueue(链表、队列)高并发

从头部取出,从尾部放数据。这有点相似ForkKoinPool中的工做窃取。源码分析

1.4 锁粗化

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

举个例子:

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){
        
    }
}

在循环内不停地获取锁。虽然JDK内部会对这个代码作些优化,可是还不如直接写成

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

固然若是有需求说,循坏不能让其余线程等待过久,那只能写成第一种形式。若是没有这样相似的需求,仍是直接写成第二种实现方式比较好。

1.5 锁消除

在即时编译时,若是发现不可能被共享的对象,则能够消除这些对象的锁操做。

也许你会以为奇怪,既然有些对象不可能被多线程访问,那为何要加锁呢?写代码时直接不加锁不就行了。

可是有些锁并非程序员所写的,好比Vector和StringBuffer这样的类,它们中的不少方法都是有锁的。当咱们在一些不会有线程安全的状况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提升性能。

例如:

public static void main(String args[]) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 2000000; i++) {
            createStringBuffer("JVM", "Diagnosis");
        }
        long bufferCost = System.currentTimeMillis() - start;
        System.out.println("craeteStringBuffer: " + bufferCost + " ms");
}

public static String createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
}

上述代码中的StringBuffer.append是一个同步操做,可是StringBuffer倒是一个局部变量,而且方法也没有把StringBuffer返回,因此不可能会有多线程去访问它。

那么此时StringBuffer中的同步操做就是没有意义的。

开启锁消除是在JVM参数上设置的,固然须要在server模式下:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

而且要开启逃逸分析。逃逸分析的做用呢,就是看看变量是否有可能逃出做用域的范围。

好比上述的StringBuffer,上述代码中createStringBuffer的返回是一个String,因此这个局部变量StringBuffer在其余地方都不会被使用。若是将createStringBuffer改为:

public static StringBuffer craeteStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
}

那么这个StringBuffer被返回后,是有可能被任何其余地方所使用的。那么JVM的逃逸分析能够分析出,这个局部变量StringBuffer逃出了它的做用域,锁就不会被消除。

当JVM参数为:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

输出:

craeteStringBuffer: 302 ms

JVM参数为:

-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks

输出:

craeteStringBuffer: 660 ms

显然,锁消除的效果仍是很明显的。

2. 虚拟机内的锁优化

首先要介绍下对象头,在JVM中,每一个对象都有一个对象头。

  • Mark Word,对象头的标记,32位
  • 描述对象的hash、锁信息,垃圾回收标记,年龄

           – 指向锁记录的指针
           – 指向monitor的指针
           – GC标记
           – 偏向锁线程ID

简单来讲,对象头就是要保存一些系统性的信息。

2.1 偏向锁

  • 大部分状况是没有竞争的,因此能够经过偏向来提升性能
  • 所谓的偏向,就是偏爱,即锁会偏向于当前已经占有锁的线程
  • 将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark
  • 只要没有竞争,得到偏向锁的线程,在未来进入同步块,不须要作同步
  • 当其余线程请求相同的锁时,偏向模式结束
  • --XX:+UseBiasedLocking(默认开启)
  • 在竞争激烈的场合,偏向锁会增长系统负担(每次都要加一次是否偏向的判断)

偏向锁的例子:

package test;

import java.util.List;
import java.util.Vector;

public class Test {
    public static List<Integer> numberList = new Vector<Integer>();

    public static void main(String[] args) throws InterruptedException {
        long begin = System.currentTimeMillis();
        int count = 0;
        int startnum = 0;
        while (count < 10000000) {
            numberList.add(startnum);
            startnum += 2;
            count++;
        }
        long end = System.currentTimeMillis();
        System.out.println(end - begin);
    }

}

Vector是一个线程安全的类,内部使用了锁机制。每次add都会进行锁请求。上述代码只要main一个线程在反复add请求锁。使用以下的JVM参数来设置偏向锁:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

BiasedLockingStartupDelay表示系统启动几秒钟后启用偏向锁。默认为4秒,缘由在于,系统刚启动时,通常数据竞争是比较激烈的,此时启用偏向锁会下降性能。

因为这里为了测试偏向锁的性能,因此把延迟偏向锁的时间设置为0。

输出:9209

下面关闭偏向锁:

-XX:-UseBiasedLocking 

输出:9627 

通常在无竞争时,启用偏向锁性能会提升5%左右

2.2 轻量级锁

Java的多线程安全是基于Lock机制实现的,而Lock的性能每每不如人意。

缘由是,monitorenter与monitorexit这两个控制多线程同步的bytecode原语,是JVM依赖操做系统互斥(mutex)来实现的。

互斥是一种会致使线程挂起,并在较短的时间内又须要从新调度回原线程的,叫我消耗资源的操做。

  • 普通的锁处理性能不够理想,轻量级锁是一种快速的锁定方法。
  • 若是对象没有被锁定
  •     – 将对象头的Mark指针保存到锁对象中
  •     – 将对象头设置为指向锁的指针(在线程栈空间中)

轻量级锁的总结

  • 若是轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁)
  • 在没有锁竞争的前提下,减小传统锁使用OS互斥量产生的性能损耗
  • 在竞争激烈时,轻量级锁会多作不少额外操做,致使性能降低。

2.3 自旋锁

当竞争存在时,由于轻量级锁尝试失败,以后有可能会直接升级成重要级锁动用操做系统层面的互斥,也有可能再尝试一下自旋锁。

  • 当竞争存在时,若是线程能够很快得到锁,那么能够不在OS层挂起线程,让线程作几个空操做(自旋),而且不停地尝试拿到这个锁(相似tryLock),固然循坏的次数是有限制的,当循坏次数达到之后,仍然会升级成重量级锁。
  • JDK1.6中-XX:+UseSpinning开启
  • JDK1.7中,去掉此参数,改成内置实现
  • 若是同步块很长,自旋失败,会下降系统性能
  • 若是同步块很短,自旋成功,节省线程挂起切换时间,提高系统性能

偏向锁,轻量级锁,自旋锁总结:

  • 不是Java语言层面的锁优化方法,是内置在JVM当中的
  • 首先偏向锁是为了不某个线程反复获取/释放同一把锁时的性能消耗,若是仍然是同个线程去得到这个锁,尝试偏向锁时会直接进入同步块。不须要再次得到锁
  • 轻量级锁自旋锁都是为了不直接调用操做系统层面的互斥操做由于挂起线程是一个很消耗资源的操做
  • 获取锁的优化方法和获取锁的步骤:
         – 偏向锁可用会先尝试偏向锁
         – 轻量级锁可用会先尝试轻量级锁
         – 以上都失败(说明存在竞争),尝试自旋锁
         – 再失败,尝试普通锁,使用OS互斥量在操做系统层挂起

3 一个错误使用锁的案例

public class IntegerLock {
    static Integer i = 0;

    public static class AddThread extends Thread {
        public void run() {
            for (int k = 0; k < 100000; k++) {
                synchronized (i) {
                    i++;
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AddThread t1 = new AddThread();
        AddThread t2 = new AddThread();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

一个很初级的错误在于,Integer是final不变的,每次++后,会产生一个新的Integer再赋值给i,因此两个线程竞争的锁是不一样的。因此并非线程安全的。

4 ThreadLocal及其源码分析

这里来提ThreadLocal可能有点不合适,可是ThreadLocal是能够把锁代替的方式。因此仍是有必要提一下。

基本的思想就是,在一个多线程当中须要把有数据冲突的数据加锁,使用ThreadLocal的话,为每个线程都提供一个对象。不一样的线程只访问本身的对象,而不访问其余的对象。这样锁就不必存在了。

package test;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {
    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("2016-02-16 17:00:" + 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并不线程安全的,因此上述代码是错误的使用。最简单的方式就是,本身定义一个类去用synchronized包装(相似于Collections.synchronizedMap)。这样作在高并发时会有问题,对synchronized的争用致使每一次只能进去一个线程,并发量很低。这里使用ThreadLocal去封装SimpleDateFormat就解决了这个问题。

package test;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Test {
    static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();

    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"));
                }
                Date t = tl.get().parse("2016-02-16 17:00:" + 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对象:

if (tl.get() == null)

若是没有的话,就new个SimpleDateFormat与当前线程绑定:

tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

而后用当前线程的SimpleDateFormat去解析:

tl.get().parse("2016-02-16 17:00:" + i % 60);

一开始的代码中,只有一个SimpleDateFormat,使用了ThreadLocal,为每个线程都new了一个SimpleDateFormat。须要注意的是,这里不要把公共的一个SimpleDateFormat设置给每个ThreadLocal,这样是没用的。须要给每个都new一个SimpleDataFormar。

hibernate中,对ThreadLocal有典型的应用。

下面来看一下ThreadLocal的源码实现

首先Thread类中有一个成员变量:

ThreadLocal.ThreadLocalMap threadLocals = null;

而这个Map就是ThreadLocal的实现关键:

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

根据ThreadLocal能够set和get相对应的value。这里的ThreadLocalMap实现和HashMap差很少,可是在hash冲突的处理上有区别。ThreadLocalMap中发生hash冲突时,不是像HashMap这样用链表来解决冲突,而是将索引++,放到下一个索引来解决冲突。

相关文章
相关标签/搜索