Java并发设计的7条原则

2 阅读7分钟

Java并发设计的7条原则

在Java并发编程的世界中,高效且安全地管理线程交互是一项至关重要的挑战

本文基于Effective Java 并发章节总结的7条原则

这些原则旨在帮助开发者规避常见的并发陷阱,确保程序在多线程环境下的正确性和性能表现

同步访问共享可变数据

在并发中多线程同时访问共享可变的数据是线程不安全的,因为我们无法预估线程的执行顺序,如果不使用一些手段那么可能导致数据不一致的问题

在这段代码中,启动一个线程:只要stopRequested不为true就循环自增,而主线程睡眠1秒后将stopRequested改为true,想让启动的那个线程暂停

	private static boolean stopRequested;	

	private static void method() throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
            System.out.println(i);
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }

但是这段代码会导致无限循环,因为Java内存模型的关系,主线程的修改对启动的线程是不可见的(类比缓存、内存)

为了保证可见性,我们需要施加一些手段,比如加同步锁,通过加锁的方式保证可见性

    private static void methodSync() throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested())
                i++;
            System.out.println(i);
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

又或者使用volatile保证可见性,这样原来那段代码则不会无限循环

	private static volatile boolean stopRequested;

对volatile、synchronized不太熟悉的同学可以查看并发专栏下的文章:

5个案例和流程图让你从0到1搞懂volatile关键字

15000字、6个代码案例、5个原理图让你彻底搞懂Synchronized

并发访问共享可变数据时可以使用volatile或synchronized保证可见性

如果只是读取数据volatile更轻量级,如果需要读写操作则可以使用同步手段synchronized

避免过度同步

同步会带来性能开销,并不是所有操作都是需要同步的

比如Vector、Collections.synchronizedCollection大量使用直接加锁锁住整个方法,性能开销大,过度使用同步

    public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);

        return elementData(index);
    }

要避免过度使用同步并且同步区域中要做尽量少的操作

现在应该多了解、使用JUC包下的一些并发组件,它们不盲目的使用同步,而是结合volatile保证读数据的可见性和一些同步手段来实现写操作的数据一致性,从而成为高性能的并发组件/容器

感兴趣的同学也可以查看并发专栏下的并发组件/容器:

10分钟从源码级别搞懂AQS(AbstractQueuedSynchronizer)

13分钟聊聊并发包中常用同步组件并手写一个自定义同步组件

5分钟从0到1探秘CopyOnWriteArrayList

12张图一次性搞懂高性能并发容器ConcurrentLinkedQueue

9张图深入剖析ConcurrentHashMap

executor、task、stream优于线程

频繁创建、销毁线程开销会很大,使用线程池对线程进行管理,有利于节省资源

JUC包下提供Executor框架,将工作任务task与执行executor分离,并且还提出fork join分治思想的并发框架(并行stream基于它)

这种并发框架都是优于单启线程的,但使用的前提是需要去熟悉这些框架

感兴趣的同学可以查看并发专栏下的并发框架:

12分钟从Executor自顶向下彻底搞懂线程池

并发工具优先于wait、notify

wait、notify结合synchronized实现的等待通知模式

并发包下基于AQS实现的并发工具(CountDownLatch...)能更方便的解决wait\notify能解决的问题

除非要维护老项目使用的wait、notify,否则优先使用并发工具,能够更简单、方便

线程安全性的文档化

当设计的类可能被客户端并发调用时在文档上说明线程安全级别

  1. 不可变:实例不可变对象,无论如何调用都是线程安全的
  2. 绝对线程安全:实例为可变对象,但提供的API确保绝对线程安全,调用方不需要使用同步机制,比如原子类
  3. 相对线程安全:实例为可变对象,提供的API保证线程安全,但调用方使用复合操作可能导致线程不安全,比如ConcurrentHashMap
  4. 线程不安全:实例为可变对象,提供API不保证线程安全,需要调用方保证线程安全,比如HashMap

谨慎延迟初始化

延迟初始化实际上就是懒加载,使用到再去进行初始化,把初始化需要耗费的时间弥补到第一次使用

因为大多数情况下总是要使用到的,所以都应该直接初始化而不是懒加载

如果需要懒加载,可以选择以下几种方式:

  1. 对于静态字段可以使用类加载机制保证只初始化一次

        //使用静态内部类存储静态字段
        private static class FieldHolder {
            static final FieldType field = computeFieldValue();
        }
    
        private static FieldType getField() {
            return FieldHolder.field;
        }
    
        //创建对象 初始化
        private static FieldType computeFieldValue() {
                return new FieldType();
        }
    
  2. 对于实例字段可以使用双重检测保证只初始化一次

        //volatile 禁止指令重排序
        private volatile FieldType field4;
    
        private FieldType getField4() {
            FieldType result = field4;
            //第一次检测
            if (result != null)    
                return result;
    
            synchronized (this) {
                //第二次检测
                if (field4 == null) 
                    //初始化
                    field4 = computeFieldValue();
                return field4;
            }
        }
    
  3. 如果可以接受重复初始化则考虑使用单重检测(不加锁初始化)

        private volatile FieldType field5;
    
        private FieldType getField5() {
            FieldType result = field5;
            if (result == null)
                field5 = result = computeFieldValue();
            return result;
        }
    

不要依赖于线程调度器

线程调度器决定哪个线程能够执行

如果依赖于线程调度器来完成任务,那么程序是不健壮、不可移植的

类似Thread.yield礼让线程 或 调整线程优先级都属于依赖线程调度器,因为无法预估线程的执行顺序

即使在当前操作系统和虚拟机上能完成,在其他环境下也有可能失败,这是无法预估的,因为它们依赖于OS中的线程调度器

总结

对于共享可变数据,如果只读可以使用volatile保证可见性,如果需要写则要使用同步手段

过度使用同步手段会导致开销大,尽量在同步区间少做操作

并发包下的Executor框架将任务与执行分离,使用线程池管理线程,还有并行stream的fork join框架都优于单独使用线程

并发包下的工具使用更简单,了解后尽量使用并发包下的工具

对于可能被并发调用的类需要声明线程安全性文档:绝对线程安全、相对线程安全、线程不安全等

延迟初始化只是把初始化的开销放到第一次使用,大多数情况下还是直接初始化,如果需要可以考虑类加载保证一次初始化或双重检测保证一次初始化

程序的编写不要依赖于线程调度器,这样的程序是不健壮、不可移植的

最后(不要白嫖,一键三连求求拉~)

本篇文章被收入专栏 Effective Java,感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 Gitee-CaiCaiJavaGithub-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~

有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜