Java多线程--锁的优化

提高锁的性能

减少锁的持有时间

一个线程如果持有锁太长时间,其他线程就必须等待相应的时间,如果有多个线程都在等待该资源,整体性能必然下降。所有有必要减少单个线程持有锁的时间。比如下面的代码:

public synchronized void someMethods() {
    fun1();
    fun2();
    // other code
    funNeedToSync();
    // other code
    fun3();
    fun4();
}

如果fun1~fun4都是耗时任务的话,对someMethods()进行同步将耗费大量时间,但实际上只有funNeedToSync()需要同步,所以只需要对部分代码进行同步。优化后如下:

public void someMethods() {
    fun1();
    fun2();
    // other code
    synchronized {
        funNeedToSync();
    }
    // other code
    fun3();
    fun4();
}

这样就减少了锁占有的时间。

降低锁粒度

如何要对HashMap的put和get方法进行同步,可以对整个HashMap加锁,这样做锁的粒度就太大了。在JDK1.7中,ConcurrentHashMap的内部进一步细分了若干个小的HashMap,称为段(Segment),默认ConcurrentHashMap被分为16个段。在进行put操作时,根据hashcode得到要存入的值应该被放置到哪个段中,只需对该段加锁即可。如果要存入的值被分配到了不同的段中,则在多线程中可以真正并行进行put操作。注意,ConcurrentHashMap在JDK8中的实现和上述略不同。在这里只是举个锁粒度优化的例子。所谓降低锁粒度,就是指缩小锁定对象的范围,从而减少锁冲突的可能性。

读写分离替代独占锁

因为读取操作并不改变值,所以应该允许多个线程同时读,即读-读不阻塞,比如ReadWriteLock读写锁,在读多写少的情况下能大大提升性能。

锁分离

LinkedBlockingQueue是基于链表实现的,take和put操作分别对队列头和队列尾操作,这两者并不冲突。如果使用独占锁,则需要获得队列的锁,那么在take的时候就不能put,put的时候也不能take;如果锁分离了,如下,正是LinkedBlockingQueue使用的额策略:

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

take操作使用一把锁,put操作也有自己的一把锁,则实现了take和put的操作互相不阻塞。只有在多个take和多个put之间才会有锁竞争的,采用这种策略降低了锁竞争的可能性。

锁粗化

虚拟机在遇到连续对同一个锁不断进行请求和释放的操作时,会把所有的锁操作整合对锁的一次请求,从而减少对锁的请求同步次数。比如

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

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

上述的第一段代码对锁lock连续请求、释放了100次...其实只需要在外层申请一次即可。

Java虚拟机对锁的优化

  • 锁偏向:如果一个线程获得了锁,锁就进入了偏向模式。当这个线程再次请求锁时,无需再做任何同步操作。
  • 轻量级锁:如果偏向锁失败,虚拟机不会立即挂起线程,会使用一种称为 轻量级锁 的优化手段,如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁夹加锁失败,表示其他线程抢先拿到了锁,当前线程的锁就会膨胀为 重量级锁
  • 自旋锁:锁膨胀后,虚拟机为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力--自旋锁。虚拟机会让当前线程做几个空循环,若干次循环后如果得到了锁,就顺利进入临界区;如果还是没得到,这才真是地将线程在操作系统层面挂起。
  • 锁消除:Java虚拟机在JIT编译时,通过扫描上下文,去除不可能存在共享资源竞争的锁。线程中的局部变量时线程的私有数据,不会跑到其他线程中去,因而不存在“共享”,锁消除的这一项关键技术称为 逃逸分析 ,即观察某一个变量是否会逃出某一个作用域,如果不会逃出,则将线程内的加锁操作去除。

ThreadLocal

使用锁是因为多个线程要访问同一个共享资源。换种思路,如果资源不是共享的,而是每个线程都有一个属于自己的资源呢?ThreadLocal就是这个思路,顾名思义这是一个线程的局部变量。只有当前线程可以访问到,自然是线程安全的。

public static ThreadLocal<SimpleDateFormat> t = new ThreadLocal<>();

// class XXX implements Runnable
@Override
public void run() {
    try {
        if (t.get() == null) {
            t.set(new SimpleDateFormat("yyyy-MM-dd"));
        } else {
            Date d = t.get().parse("2018-05-10");
        }
    } catch (ParseException e) {
        e.printStackTrace();
    }
}

上面举了个ThreadLocal的例子,从代码中可以看到,如果当前线程没有持有一个SimpleDateFormat就为其新建一个,如果有了就直接取出来用,在这里ThreadLocal为没个线程都准备了一个局部变量,这个局部变量在这里就是SimpleDateFormat。注意这里为没个线程设置新的对象t.set(new SimpleDateFormat("yyyy-MM-dd"));保证了线程安全,如果设置的对象是同一个,那不能保证线程安全

set和get是怎么实现设置为每一个线程分配一个局部变量的呢?get和set用到了Map,将当前线程对象和这个局部变量绑定在一起。

get和set的核心实现就是

public void set(T value) {
    // other code
    map.set(this, value); // 以当前线程对象为key,局部对象为value存入map中
    // other code
}

public T get() {
    // other code
    ThreadLocalMap.Entry e = map.getEntry(this); // 以当前线程对象为key,取得当前线程的局部变量
    // other code
}

ThreadLocal的实现用到了ThreadLocalMap,可以理解成一个Map,这个Map中就存放了各个线程的所有“局部变量”。

无锁

无锁使用CAS(Compare And Swap)

CAS包含三个参数(V, E, N)分别是当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。当多个线程同时使用CAS操作同一个变量时,只有一个线程能成功更新,其余线程均会操作失败。失败的线程不会被挂起,仅被告知失败,还允许再次尝试。CAS操作中这个期望值通俗点说就是当前线程认为这个变量现在的值应该是多少,如果变量的值V并不是期望的那样,说明该变量被其他线程修改过了。当前线程可以再次尝试修改。

锁的使用是悲观的,它总是假设每一次临界区操作都会产生冲突,所以只有一个线程能进入临界区而其他线程只好在临界区外等待;无锁的CAS是乐观的,它假设对资源的访问不存在冲突,那么所有的线程都不用等待,一刻不停地执行,如果真的遇到了冲突,再进行CAS操作,不断重新尝试直到没有冲突。

Java中的无锁类

JDK中有个atomic包,里面有一些直接使用了CAS操作的线程安全的类型。

AtomicInteger和Integer都表示整数,但是AtomicInteger是可变且线程安全的,它的内部使用而来CAS操作。类似的还有AtomicLong和AtomicBloolean。

AtomicReference和AtomicInteger类似,前者是对整数的封装,后者是它是对普通对象的封装。之前有说CAS操作会判断当前内存值和期望值是否一致,一致就用新值更新当前值。注意,仅仅是判断了值一致,值变化了多次又变回了原来的样子,CAS操作就无法判断这个对象是否被修改过。也就是说CAS操作只比较最终结果,当前线程无法得知该对象的状态变化过程,如果要获得对象被修改过程的状态变化AtomicReference就不适用了,此时可以使用带时间戳的AtomicStampedReference,不仅维护了对象值,还维护了一个时间戳(就是一个状态值,其实可以不用时间戳),当对象的值被修改时,除了更新数据本身,还要更新时间戳;当设置对象值时,必须满足对象值和时间戳都和期望值一致,写入才会成功。因此即使对象值被反复修改,最后回到了原来的值吗,只要时间戳变了,就能防止不恰当的写入。

Java中也读数组进行了封装,可以使用的原子数组有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,分别表示整数数组、Long型数组和对象数组。

普通变量也可以使用原子操作,有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater,分别对int型ling型和对象的的普通变量进行CAS操作。这几个类可以对对象中的属性字段进行CAS操作而不用担心线程安全的问题,举个例子

public class Demo {
    public static class Student {
        public int id;
        public volatile score;
    }
    public final static AtomicIntegerFielUpdater<Student> scoreUpdater = AtomicIntegerFielUpdater.newUpdater(Sdudent.class, "score");
}

像上面的例子就实现了对Sdudent类的score属性进行CAS操作以保证其线程安全。

AtomicIntegerFieldupdater很好用,但是有几点要注意:

  • Updater使用反射得到这个变量,所以如果变量不可见就会出错。如果上面Student类中score是private的就不可以;
  • 为了保证变量对正确读取,它必须是volatile类型的;
  • CAS操作会通过对象实例中的偏移量直接进行赋值,所以不支持static字段,以为内Unsafe.objectFieldOffset()不支持静态变量。

死锁

死锁就是两个或者多个线程,相互占用着对方需要的资源,都不释放,导致彼此之间相互等待对方释放资源,产生了无限制的等待。举个简单的例子

public class DeadLock implements Runnable {

    public static Object fork1 = new Object();
    public static Object fork2 = new Object();

    private String name;
    private Object tool;

    public DeadLock(Object o) {
        this.tool = o;
        if (tool == fork1) {
            this.name = "哲学家A";
        }
        if (tool == fork2) {
            this.name = "哲学家B";
        }
    }

    @Override
    public void run() {
        if (tool == fork1) {
            synchronized (fork1) {
                try {
                    System.out.println(name+"拿到了一个叉子");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (fork2) {
                    System.out.println(name+"拿到两个叉子了");
                }
            }
        }

        if (tool == fork2) {
            synchronized (fork2) {
                try {
                    System.out.println(name+"拿到了一个叉子");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (fork1) {
                    System.out.println(name+"拿到两个叉子了");
                }
            }
        }
    }

    public static void main(String[] args) {
        DeadLock a = new DeadLock(fork1);
        DeadLock b = new DeadLock(fork2);

        Thread t1 = new Thread(a);
        Thread t2 = new Thread(b);
        t1.start();
        t2.start();

    }
}

运行上面这段程序,会输出

哲学家B拿到了一个叉子
哲学家A拿到了一个叉子

然后程序就进入了死循环,因为哲学家A在等B手里的叉子,哲学家B也在等A手上的叉子,但是他俩谁都不肯释放。

为了规避死锁,除了使用无锁操作外,还可以使用重入锁。


by @sunhaiyu

2018.5.13

Java多线程--锁的优化的更多相关文章

  1. synchronized与static synchronized 的差别、synchronized在JVM底层的实现原理及Java多线程锁理解

    本Blog分为例如以下部分: 第一部分:synchronized与static synchronized 的差别 第二部分:JVM底层又是怎样实现synchronized的 第三部分:Java多线程锁 ...

  2. Java多线程——锁概念与锁优化

    为了性能与使用的场景,Java实现锁的方式有非常多.而关于锁主要的实现包含synchronized关键字.AQS框架下的锁,其中的实现都离不开以下的策略. 悲观锁与乐观锁 乐观锁.乐观的想法,认为并发 ...

  3. Java 多线程 锁 存款 取款

    http://jameswxx.iteye.com/blog/806968 最近想将java基础的一些东西都整理整理,写下来,这是对知识的总结,也是一种乐趣.已经拟好了提纲,大概分为这几个主题: ja ...

  4. Java多线程——锁

    Java多线系列文章是Java多线程的详解介绍,对多线程还不熟悉的同学可以先去看一下我的这篇博客Java基础系列3:多线程超详细总结,这篇博客从宏观层面介绍了多线程的整体概况,接下来的几篇文章是对多线 ...

  5. Java 多线程 - 锁优化

    http://www.cnblogs.com/pureEve/p/6421273.html https://www.cnblogs.com/mingyao123/p/7424911.html

  6. 详解Java多线程锁之synchronized

    synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法. synchronized的四种使用方式 修饰代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号{}括 ...

  7. [java多线程] - 锁机制&同步代码块&信号量

    在美眉图片下载demo中,我们可以看到多个线程在公用一些变量,这个时候难免会发生冲突.冲突并不可怕,可怕的是当多线程的情况下,你没法控制冲突.按照我的理解在java中实现同步的方式分为三种,分别是:同 ...

  8. Java——多线程锁的那些事

    引入 Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率. 下面先带大家来总体预览一下锁的分类图 1.乐观锁 VS 悲观锁 乐观锁与悲观锁是一种广义上的概念,体现了 ...

  9. java多线程-锁

    自 Java 5 开始,java.util.concurrent.locks 包中包含了一些锁的实现,因此你不用去实现自己的锁了.但是你仍然需要去了解怎样使用这些锁. 一个简单的锁 让我们从 java ...

随机推荐

  1. .NET MVC 学习笔记(六)— 数据导入

    .NET MVC 学习笔记(六)—— 数据导入 在程序使用过程中,有时候需要新增大量数据,这样一条条数据去Add明显不是很友好,这时候最好就是有一个导入功能,导入所需要的数据,下面我们就一起来看一下导 ...

  2. nodejs学习(imooc课程笔记, 主讲人Scott)

    课程地址: 进击Node.js基础(一) 进击Node.js基础(二) 1. nodejs创建服务器 var http = require('http'); //加载http模块 //请求进来时, 告 ...

  3. “全栈2019”Java多线程第二十九章:可重入锁与不可重入锁详解

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

  4. WINDOWS平台下的栈溢出攻击从0到1(篇幅略长但非常值得一看)

    到1的这个过程.笔者也希望能够通过这些技术分享帮助更多的朋友走入到二进制安全的领域中.2.文章拓扑由于本篇文章的篇幅略长,所以笔者在这里放一个文章的拓扑,让大家能够在开始阅读文章之前对整个文章的体系架 ...

  5. poi 读取使用 Strict Open XML 保存的 excel 文档

    poi 读取使用 Strict Open XML 保存的 excel 文档 某项目有一个功能需要读取 excel 报表内容,使用poi读取时报错: 具体错误为: org.apache.poi.POIX ...

  6. centos7上编译安装mysql5.6

    注意,在做实验室统一关闭防火墙做的,在生产环境需要做防火墙规则的,大家要注意,做的时候尽量都是模仿生产环境的,比如服务一般都在/data/soft下面,尽量避免在/usr/local/下面. 安装编译 ...

  7. Others - On Duty

    On Duty This is xxx and will be duty engineer in the next week. Thanks. Here is a kindly reminder. T ...

  8. eclipse安装STS遇到的问题

    eclipse安装STS时,在eclipse marketplase中搜索STS没有结果,从官网下载STS包,然后安装提示找不到JAR包, 解决方式: eclipse需要和STS包版本一致,如果STS ...

  9. JavaScript -- Window-Scroll

    -----037-Window-Scroll.html----- <!DOCTYPE html> <html> <head> <meta http-equiv ...

  10. nginx服务器搭建以及配置

    2019年第一篇博客,在新的一年祝大家新年快乐,技术更上一层楼. 今天在公司搞了好长时间的nginx服务器搭建,以及遇到的问题,总结一下,方便查询 这里使用的是百度云的服务器,CentOS7系统的 N ...