欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

【JUC多线程与高并发】多线程进阶,性能优化之锁优化

程序员文章站 2022-07-03 09:38:44
...

博客地址(点击即可访问) github源码地址
深刻理解JMM(JAVA内存模型) https://github.com/zz1044063894/JMMprojcet
volatile详解 https://github.com/zz1044063894/volatile
线程基础,java并发程序基础 https://github.com/zz1044063894/thread-base
线程进阶,JDK并发包 https://github.com/zz1044063894/JDK-concurrency
多线程进阶,性能优化之锁优化 https://github.com/zz1044063894/lock-optimization
线程进阶,性能优化之无锁 https://github.com/zz1044063894/no-lock

对于优化锁的建议

在高并发环境下,激烈的锁竞争会导致程序的性能下降,所以我们必须讨论一下关于锁优化的问题,

减少锁持有时间

在锁竞争的过程中,单个线程对锁的持有时间与形同有着直接的关系。如果线程持有锁的时间很长,那么,锁的竞争成都也就越激烈。就比如有一个愿望实现的报名活动,但是每个人只能又一个愿望,而且需要用纸写下来,加入有10个人报名,但是只有一支笔,那么我们就不能让没有想好的人先拿着笔,这样会影响整体的工作效率。
比如这段伪代码

 public synchronized void syncMeth(){
	task1();
	task2();
	task3();
} 

在上面的方法中,若只有task2()需要同步,而其他两个不需要同步,那么我们在最外层方法上加了锁,必然会景象性能。我们只需要将task2()中的代码加锁就可以了,这样可以大大减少锁的持有时间,从而达到优化性能的目的。
如下

 public  void syncMeth(){
	task1();
	synchronized(this){
		task2();
	}
	task3();
} 

减少锁粒度

减少锁粒度也是一个削弱多线程锁竞争的常用手段。

这里我们以HashMap来说,它比较重要的两个方法为get()和put()。如果我们想让其实现线程安全,很多人一下就想到对整个数据结构加锁,这样做肯定可以达到目的,但是,非常耗费性能,我们就认为这样的加锁粒度太大了。前面我们说了ConcurrentHashMap是线程安全的,它内部进一步细分了若干个HashMap,称之为段。默认情况下被分成16段。
如果在ConcurrentHashMap中新增一个内容,不会将整个HashMap加锁,而是首先根据hashcode得到该内容应该储存在哪段,然后对该段加锁,并且完成put操作,在多线程的情况下,只要插入操作不再同一段,那么就可以同时操作。

多用读写锁代替独占锁

在前面的博客中,笔者已经详细讲过读写锁相较于普通锁的优势。读写锁

锁分离

如果对读写锁的思想进一步眼神,就是锁分离。读写锁是根据读写的功能不同,进行了有效的锁分离。而我们可以学习这样的思想,根据功能来进行锁分离。

一个典型的例子就是jdk中LinkBlockingQueue的实现,有兴趣的小伙伴可以看一下源码,接下来笔者讲讲大概的设计理念:
在内部实现了take锁和put锁,给take()方法和put()方法分别加锁的操作实现了方法内部互不干扰,在多线程下,他们不需要争整个锁,只需要自己执行方法的锁就可以了,这样也对性能优化有着很大的帮助

锁粗化

前面笔者说到尽量减少锁的持有时间,使用完锁的时候尽快释放锁,但是有一种特殊的业务场景这样也是不好的,就是要对同一个锁不停的进行请求、同步和释放,这样反而就不利于性能的优化.
请看下面的例子

    public void coarseningLock(){
        //锁粗化之前
        int n = 10;
        for(int i=0;i<n;i++){
            synchronized (this) {
                //TODO: 要做的事情
            }
        }
        //锁粗化之后
        synchronized (this){
            for(int i=0;i<n;i++){
                //TODO: 要做的事情
            }
        }
    }

注意:性能优化就是根据运行情况对各个资源点进行考究,使程序更好的运行的过程。锁粗化和减少锁持有时间是两种相反的操作,适用于不同的场合,需要根据实际情况使用。

java虚拟机对锁优化的支持

锁偏向

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

轻量级锁

如果偏向锁失败,虚拟机不会立即挂起线程。它会使用一种称为轻量级锁的优化手段。轻量级锁的操作很轻便,它只是简单地将对象头部作为指针,只想持有锁的线程对战的内部,来判断一个线程是否持有对象,如果线程获取了轻量级锁,则可以顺利进入临界区,如果失败,则说明其他线程抢先占有锁,这么当前线程就会变成重量级锁的请求。也就是synchronized请求。

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点:
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,占着XX不XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁;

自旋锁的开启
JDK1.6中-XX:+UseSpinning开启; 
-XX:PreBlockSpin=10 为自旋次数; 
JDK1.7后,去掉此参数,由jvm控制;

锁消除

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

你或许会有疑问面,如果不存在竞争那么为什么会加锁呢?因为java开发中可能会使用一些jdk内置的api,比如stringbuffer,vector等,在使用的时候通常不会考虑他们的底层是如何实现的。

ThreadLocal

在JDK 1.2的版本中就提供java.lang.ThreadLocal,ThreadLocal为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序。
ThreadLocal并不是一个Thread,而是Thread的局部变量,也许把它命名为ThreadLocalVariable更容易让人理解一些。
在JDK5.0中,ThreadLocal已经支持泛型,该类的类名已经变为ThreadLocal。API方法也相应进行了调整,新版本的API方法分别是void set(T value)、T get()以及T initialValue()】

使用方法

从名字上来看,这是一个线程的局部变量。也就是只有当前线程可以访问,所以一定是线程安全的。

package com.jingchu.juc.optimization;

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

/**
 * @description: ThreadLocal使用
 * @author: JingChu
 * @createtime :2020-07-23 19:22:25
 **/
public class MyThreadLocal {
    static ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<SimpleDateFormat>();
    public static class ParesDate implements Runnable {
        int i = 0;

        public ParesDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                if(threadLocal.get()==null){
                    threadLocal.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:sss"));
                }
                Date d = threadLocal.get().parse("2020-07-23 19:25:" + i % 60);
                System.out.println(d.toString());
            } catch (ParseException e) {
                e.printStackTrace();
            } finally {

            }
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for(int i=0;i<100;i++){
            executorService.execute(new ParesDate(i));
        }
    }
}

原理

首先 ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。

因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,这个 Map 不是直接使用的 HashMap ,而是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。而我们使用的 get()、set() 方法其实都是调用了这个ThreadLocalMap类对应的 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);
    }
    public T get() {   
        Thread t = Thread.currentThread();   
        ThreadLocalMap map = getMap(t);   
        if (map != null)   
            return (T)map.get(this);   
  
        // Maps are constructed lazily.  if the map for this thread   
        // doesn't exist, create it, with this ThreadLocal and its   
        // initial value as its only entry.   
        T value = initialValue();   
        createMap(t, value);   
        return value;   
    }

在set时,首先获得当前线程对象,然后通过getMap()拿到线程的ThreadLocalMap,并将值设置ThreadLocalMap中。而ThreadLocalMap可以理解为一个map,一个定义在Thread内部的成员。设置到ThreadLocal的内容也是写入ThreadLocal这个map中。我们在get时,就是后去这个Map中的内容。