多线程与并发(二)——性能优化

1 锁优化

1.1 synchronized优化

synchronized使用起来非常简单,但是需要注意的是synchronized加锁的是什么维度。
对象级别:

    public synchronized void test() {
        // TODO
    }
    public void test() {
        synchronized (this) {
            // TODO
        }
    }

类级别:

    public synchronized static void test1() {
        // TODO
    }
    public static void test2() {
        synchronized (SynchronizedOpt.class) {
            // TODO
        }
    }

案例:看一个加锁粒度过粗的案例

public class BadSync implements Runnable {
    volatile long totalTime = 0;
    long start = System.currentTimeMillis();
    int i = 0;

    public void inc() {
        i++;
    }

    @Override
    public synchronized void run() {
        try {
            //读取数据库,一堆耗时操作
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //更新计数器
        inc();
        totalTime += (System.currentTimeMillis() - start);
    }
    public static void main(String[] args) throws InterruptedException {
        BadSync badSync = new BadSync();
        for (int i = 0; i < 5; i++) {
            new Thread(badSync).start();
        }
        Thread.sleep(3000);
        System.out.println("计数器:" + badSync.i);
        System.out.println("最后耗时:" + badSync.totalTime);
    }
}

改进方案:
synchronized关键字加在inc()方法上

1.2 Lock锁优化

看一个小需求:电商系统中记录首页被用户浏览的次数,以及最后一次操作的时间(含读或写)。
优化前:

public class TotalLock {
    // 类启动时间
    private final Long start = System.currentTimeMillis();

    //总耗时
    private AtomicLong totalTime = new AtomicLong(0L);
    // 缓存变量
    private Map<String,Long> map = new HashMap(){{put("count",0L);}};

    private ReentrantLock lock = new ReentrantLock();
    // 查看写入map次数
    public Map read() {
        lock.lock();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        // 最后操作完成时间
        map.put("timed", end);
        lock.unlock();
        System.out.println(Thread.currentThread().getName()+",read=" + (end-start));
        totalTime.addAndGet(end-start);
        return map;
    }
    // 写入操作
    public Map write() {
        lock.lock();
        try {
        	// 模拟业务耗时操作
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        map.put("count",map.get("count")+1);
        long end = System.currentTimeMillis();
        // 最后操作完成时间
        map.put("timed", end);
        lock.unlock();
        System.out.println(Thread.currentThread().getName()+",write=" + (end-start));
        totalTime.addAndGet(end-start);
        return map;
    }

    public static void main(String[] args) throws InterruptedException {
        TotalLock totalLock = new TotalLock();

        for (int i = 0; i < 4; i++) {
            new Thread(()->{
                totalLock.read();
            }).start();
        }

        new Thread(()->totalLock.write()).start();
        Thread.sleep(3000);
        System.out.println(totalLock.map);
        System.out.println("读写总共耗时:"+totalLock.totalTime.get());
    }
}

输出:

Thread-1,read=215
Thread-0,read=315
Thread-4,write=415
Thread-2,read=515
Thread-3,read=615
{timed=1596676528655, count=1}
读写总共耗时:2075

查看次数这里其实是可以并行读取的,我们关注的业务是写入次数,也就是count,至于读取发生的时间time的写入操作,只是一个put,不需要原子性保障,对这个加互斥锁没有必要。
优化:改成读写锁

public class TotalLock {
    // 类启动时间
    private final Long start = System.currentTimeMillis();

    //总耗时
    private AtomicLong totalTime = new AtomicLong(0L);
    //缓存变量,注意!因为read并发,这里换成ConcurrentHashMap
    private Map<String,Long> map = new ConcurrentHashMap(){{put("count",0L);}};
    // 读写锁
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    // 查看写入map次数
    public Map read() {
        // 读锁,共享锁
        lock.readLock().lock();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        // 最后操作完成时间
        // 使用共享锁,需要使用ConcurrentHashMap保证put操作的安全性
        map.put("timed", end);
        lock.readLock().unlock();
        System.out.println(Thread.currentThread().getName()+",read=" + (end-start));
        totalTime.addAndGet(end-start);
        return map;
    }
    // 写入操作
    public Map write() {
        lock.writeLock().lock();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        map.put("count",map.get("count")+1);
        long end = System.currentTimeMillis();
        // 最后操作完成时间
        map.put("timed", end);
        lock.writeLock().unlock();
        System.out.println(Thread.currentThread().getName()+",write=" + (end-start));
        totalTime.addAndGet(end-start);
        return map;
    }

    public static void main(String[] args) throws InterruptedException {
        TotalLock totalLock = new TotalLock();

        new Thread(()->totalLock.write()).start();
        for (int i = 0; i < 4; i++) {
            new Thread(()->{
                totalLock.read();
            }).start();
        }

        Thread.sleep(3000);
        System.out.println(totalLock.map);
        System.out.println("读写总共耗时:"+totalLock.totalTime.get());
    }
}

输出:

Thread-0,write=209
Thread-4,read=309
Thread-3,read=309
Thread-2,read=309
Thread-1,read=309
{timed=1596677561973, count=1}
读写总共耗时:1445

读操作并行,当read远大于write时,优化效果更明显。

1.3 一些经验

  • 减少锁的时间 不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放
  • 减少锁的粒度 将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争,典型如分段锁
  • 拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量相等
  • 减少加减锁的次数 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都要加锁
  • 使用读写锁 业务细分,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写
  • 善用volatile volatile的控制比synchronized更轻量化,在某些变量上可以加以运用,如单例模式中

2 线程池参数调优

2.1 executors剖析

Executors只是一个工具类,协助你创建线程池。Executors对特定场景下做了参数调优。
1)newCachedThreadPool

//core=0
//max=Integer
//timeout=60s
//queue=1
//也就是只要线程不够用,就一直开,不用就全部释放。线程数0-max之间弹性伸缩
//注意:任务并发太高且耗时较长时,造成cpu高消耗,同时要警惕OOM
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue());

2)newFixedThreadPool

//core=max=指定数量
//timeout=0
//queue=无界链表
//也就是说,线程数一直保持制定数量,不增不减,用不超时
//如果不够用,就沿着队列一直追加上去,排队等候
//注意:并发太高时,容易造成长时间等待无响应,如果任务临时变量数据过多,容易OOM
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(),threadFactory);

3)newSingleThreadExecutor

//core=max=1
//timeout=0
//queue=无界链表
//只有一个线程在慢吞吞的干活,可以认为是fix的特例
//适用于任务零散提交,不紧急的情况
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()));

4)newScheduledThreadPool

//core=制定数
//max=Integer
//timeout=0
//queue=DelayedWorkQueue(重点!)
//用于任务调度,DelayedWorkQueue限制住了任务可被获取的时机(getTask方法),也就实现了时间控制
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(),threadFactory);

2.2 一些经验

1)corePoolSize
基本线程数,一旦有任务进来,在core范围内会立刻创建线程进入工作。所以这个值应该参考业务并发量在绝大多数时间内的并发情况。同时分析任务的特性。
高并发,执行时间短的,要尽可能小的线程数,如配置CPU个数+1,减少线程上下文的切换。因为它不怎么占时间,让少量线程快跑干活。
并发不高、任务执行时间长的要分开看:如果时间都花在了IO上,那就调大CPU,如配置两倍CPU个数+1。不能让CPU闲下来,线程多了并行处理更快。如果时间都花在了运算上,运算的任务还很重,本身
就很占cpu,那尽量减少cpu,减少切换时间。
2) workQueue
任务队列,用于传输和保存等待执行任务的阻塞队列。这个需要根据你的业务可接受的等待时间。是一个需要权衡时间还是空间的地方,如果你的机器cpu资源紧张,jvm内存够大,同时任务又不是那么紧迫,减少coresize,加大这里。如果你的cpu不是问题,对内存比较敏感比较害怕内存溢出,同时任务又要求快点响应,那么减少这里。
3) maximumPoolSize
线程池最大数量,这个值和队列要搭配使用,如果你采用了无界队列,那很大程度上,这个参数没有意义。同时要注意,队列盛满,同时达到max的时候,再来的任务可能会丢失(下面的handler会讲)。如果你的任务波动较大,同时对任务波峰来的时候,实时性要求比较高。也就是来的很突然并且都是着急的。那么调小队列,加大这里。如果你的任务不那么着急,可以慢慢做,那就扔队列吧。
队列与max是一个权衡。队列空间换时间,多花内存少占cpu,轻视任务紧迫度。max舍得cpu线程开销,少占内存,给任务最快的响应。
4) keepaliveTime
线程存活保持时间,超出该时间后,线程会从max下降到core,很明显,这个决定了你养闲人所花的代价。如果你不缺cpu,同时任务来的时间没法琢磨,波峰波谷的间隔比较短。经常性的来一波。那么适当的延长销毁时间,避免频繁创建和销毁线程带来的开销。如果你的任务波峰出现后,很长一段时间不再出现,间隔比较久,那么要适当调小该值,让闲着不干活的线程尽快销毁,不要占据资源。
5) threadFactory(自定义展示实例)
线程工厂,用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。如果需要自己定义线程的某些属性,如个性化的线程名,可以在这里动手。一般不需要折腾它。
6)handler
线程饱和策略,当线程池和队列都满了,再加入线程会执行此策略。默认不处理的话会扔出异常,打进日志。这个与任务处理的数据重要程度有关。如果数据是可丢弃的,那不需要额外处理。如果数据极其重要,那需要在这里采取措施防止数据丢失,如扔消息队列或者至少详细打入日志文件可追踪。

3 上下文切换优化

3.1 基本操作

CPU通过时间片分配算法来循环执行任务,时间片一般是几十毫秒(ms)。切换就要保存旧状态,完成恢复时就要读取存储的内容。这个操作过程就是上下文的切换。

3.2 竞争锁

1)锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。上下文的切换代价就越多。
2)将锁贴近需要加锁的地方,越近越好!

3.3 wait/notify

1)过时通知

public class WaitInvalid {
    int total = 0;
    byte[] lock = new byte[0];

    //计算1-100的和,算完后通知print
    public void count() {
        synchronized (lock) {
            for (int i = 1; i < 101; i++) {
                total += i;
            }
            lock.notify();
        }
        System.out.println("count finish");
    }

    //打印,等候count的通知
    public void print() {
        synchronized (lock) {
            try {
                lock.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(total);
    }

    public static void main(String[] args) {
        WaitInvalid waitInvalid = new WaitInvalid();
        new Thread(() -> {
            waitInvalid.count();
        }).start();
        new Thread(() -> {
            waitInvalid.print();
        }).start();
    }
}

count先执行时,提前释放了notify通知,这时候,print还没进入wait,收不到这个信号。等print去wait的时候,再等通知等不到了,典型的通知过时现象。
2)额外唤醒

public class NotifyInvalid {
    List list = new ArrayList();
    byte[] lock = new byte[0];
    public void del() {
        synchronized (lock) {
            //没值就等,有值就删
            if (list.isEmpty()) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            list.remove(0);
        }
    }
    public void add() {
        synchronized (lock) {
            //加个值后唤醒
            list.add(0, 0);
            lock.notifyAll();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        NotifyInvalid notifyInvalid = new NotifyInvalid();
        //启动两个线程等候删除
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                notifyInvalid.del();
            }).start();
        }
        //新线程添加一个
        new Thread(() -> {
            notifyInvalid.add();
        }).start();
        Thread.sleep(1000);
        System.out.println(notifyInvalid.list.size());
    }
}

分析:
出异常了!因为等候的两个线程都被唤醒了,第一个线程删除集合后,第二个线程再去删除时,等待前的判空失效,直接删除会有异常。
解决方案:
线程唤醒后,要警惕睡眠前后状态不一致,要二次判断。

3.4 线程池

1)线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。
2)慎用Executors,尤其如newCachedThreadPool。这个方法前面分析过。如果任务过多会无休止创建过多线程,增加了上下文的切换。最好根据业务情况,自己创建线程池参数。

3.5 虚拟机

1)很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收旧对象时,会产生内存碎片
2)碎片内存整理中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化
3)内存地址变化就要去移动对象前暂停线程,在移动完成后需要再次唤醒。无形中增加了上下文的切换
4)结论:合理搭配JVM内存调优,减少 JVM 垃圾回收的频率可以有效地减少上下文切换

你可能感兴趣的:(学习笔记系列)