并发编程的12条规范

514次阅读  |  发布于11月以前

前言

大家好,我是田螺。最近看了一下阿里巴巴Java开发手册,整理了并发处理的12条规范,并且都给出对应代码的例子,大家看完一定会有收获的。

  1. 获取单例对象需要保证线程安全

我们在获取单例对象的时候,要确保线性安全哈。

比如双重检查锁定(Double-Checked Locking)的单例模式,就是一个经典案例,你在获取单实例对象的时候,就需要保证线性安全,比如加synchronized确保现象安全,代码如下:

public class Singleton {
    private volatile static Singleton instance;

    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

大家在写资源驱动类、工具类、单例工厂类的时候,都需要注意获取单例对象需要保证线程安全哈。

  1. 创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。

使用线程池时,如果没有给线程池一个有意义的名称,将不好排查回溯问题。

反例

public class TianLuoBoyThreadTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1, 
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20));
        executorOne.execute(()->{
            System.out.println("关注公众号:捡田螺的小男孩");
            throw new NullPointerException();
        });
    }
}

运行结果:

关注公众号:捡田螺的小男孩
Exception in thread "pool-1-thread-1" java.lang.NullPointerException
 at com.example.dto.TianLuoBoyThreadTest.lambda$main$0(ThreadTest.java:17)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
 at java.lang.Thread.run(Thread.java:748)

可以发现,默认打印的线程池名字是pool-1-thread-1,如果排查问题起来,并不友好。因此建议大家给自己线程池自定义个容易识别的名字。其实用CustomizableThreadFactory即可,正例如下

public class ThreadTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20),
                new CustomizableThreadFactory("TianluoBoy-Thread-pool"));
        executorOne.execute(()->{
            System.out.println("关注公众号:捡田螺的小男孩");
            throw new NullPointerException();
        });
    }
}
  1. 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

日常开发中,我们经常需要使用到多线程。线程资源要求通过线程池提供,而不允许显式创建线程

因为如果显示创建线程,可能造成系统创建大量同类线程而导致消耗完内存。使用线程池主要有这些好处:

反例(显式创建线程):

public class DirectThreadCreation {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new WorkerThread("Task " + i));
            thread.start();
        }
    }
}

class WorkerThread implements Runnable {
    private String taskName;

    public WorkerThread(String taskName) {
        this.taskName = taskName;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " executing " + taskName);
        // 执行任务的具体逻辑
    }
}

正例(线程池):

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建固定大小的线程池
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // 提交任务给线程池执行
        for (int i = 0; i < 10; i++) {
            Runnable task = new WorkerThread("Task " + i);
            executor.execute(task);
        }

        // 关闭线程池
        executor.shutdown();
    }
}

class WorkerThread implements Runnable {
    private String taskName;

    public WorkerThread(String taskName) {
        this.taskName = taskName;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " executing " + taskName);
        // 执行任务的具体逻辑
    }
}
  1. SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为static,必须加锁

SimpleDateFormat 是线程不安全的类,因为它内部维护了一个 Calendar 实例,而 Calendar 不是线程安全的。因此,在多线程环境下,如果多个线程共享一个 SimpleDateFormat 实例,可能会导致并发问题。

如果需要在多线程环境下使用SimpleDateFormat,可以通过加锁的方式来确保线程安全。

public class SafeDateFormatExample {
    private static final Object lock = new Object();
    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                parseAndPrintDate("2022-01-01 12:30:45");
            } catch (ParseException e) {
                e.printStackTrace();
            }
        };

        // 启动多个线程来同时解析日期
        for (int i = 0; i < 5; i++) {
            new Thread(task).start();
        }
    }

    private static void parseAndPrintDate(String dateString) throws ParseException {
        synchronized (lock) {
            Date date = sdf.parse(dateString);
            System.out.println(Thread.currentThread().getName() + ": Parsed date: " + date);
        }
    }
}
  1. 线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式

这是因为Executors 返回的线程池:

反例:

/**
 * 公众号:捡田螺的小男孩
 */
public class NewFixedTest {

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            executor.execute(() -> {
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    //do nothing
                }
            });
        }
    }
}

使用 Executors的newFixedThreadPool创建的线程池,是会有坑的,它默认是无界的阻塞队列,如果任务过多,会导致OOM问题。运行一下以上代码,出现了OOM。

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
 at java.util.concurrent.LinkedBlockingQueue.offer(LinkedBlockingQueue.java:416)
 at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1371)
 at com.example.dto.NewFixedTest.main(NewFixedTest.java:14)

这是因为ExecutorsnewFixedThreadPool使用了无界的阻塞队列的LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长(比如,上面demo代码设置了10秒),会导致队列的任务越积越多,导致机器内存使用不停飙升, 最终出现OOM。

ThreadPoolExecutor 创建的时候,需要明确配置线程池参数,可以避免资源耗尽风险。

  1. 高并发的时候,同步调用要考虑锁的粒度。

高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。

通俗易懂讲就是,在保证数据安全的情况下,尽可能使加锁的代码块工作量尽可能的小。因为在高并发场景,为了防止超卖等情况,我们经常需要加锁来保护共享资源。但是,如果加锁的粒度过粗,是很影响接口性能的。 再比如,我们不推荐在加锁的代码块中,再调用RPC方法。

对于锁的粒度,我给大家个代码例子哈:

比如,在业务代码中,有一个ArrayList因为涉及到多线程操作,所以需要加锁操作,假设刚好又有一段比较耗时的操作(代码中的slowNotShare方法)不涉及线程安全问题。反例加锁,就是一锅端,全锁住:

//不涉及共享资源的慢方法
private void slowNotShare() {
    try {
        TimeUnit.MILLISECONDS.sleep(100);
    } catch (InterruptedException e) {
    }
}

//错误的加锁方法
public int wrong() {
    long beginTime = System.currentTimeMillis();
    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
        //加锁粒度太粗了,slowNotShare其实不涉及共享资源
        synchronized (this) {
            slowNotShare();
            data.add(i);
        }
    });
    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
    return data.size();
}

正例:

public int right() {
    long beginTime = System.currentTimeMillis();
    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
        slowNotShare();//可以不加锁
        //只对List这部分加锁
        synchronized (data) {
            data.add(i);
        }
    });
    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
    return data.size();
}
  1. HashMap 在容量不够进行 resize 时由于高并发可能出现死链,导致 CPU 飙升。

HashMap 在容量不够进行 resize 时由于高并发可能出现死链,导致 CPU 飙升。在开发过程中可以使用其它数据结构或加锁来规避此风险。

在普通的 HashMap 中,可能出现死锁的场景通常与多线程并发修改 HashMap 的结构有关。这种情况下,多个线程同时对 HashMap 进行插入、删除等操作,可能导致链表形成环,进而导致死锁。

比如这个例子,演示了多线程同时对 HashMap 进行修改可能导致死锁的情况:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;

public class HashMapDeadlockExample {
    public static void main(String[] args) throws InterruptedException {
        final Map<String, String> hashMap = new HashMap<>();
        final CountDownLatch latch = new CountDownLatch(2);

        // 线程1向HashMap中插入元素
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                hashMap.put(String.valueOf(i), String.valueOf(i));
            }
            latch.countDown();
        });

        // 线程2删除HashMap中的元素
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                hashMap.remove(String.valueOf(i));
            }
            latch.countDown();
        });

        thread1.start();
        thread2.start();

        // 等待两个线程执行完成
        latch.await();

        // 打印HashMap的大小
        System.out.println("HashMap size: " + hashMap.size());
    }
}

解决或规避这个问题的方式可以使用使用ConcurrentHashMapConcurrentHashMapHashMap 的线程安全版本,它使用了分段锁(Segment)来提高并发性能,减小锁的粒度,降低了并发冲突的可能性。

8.使用 CountDownLatch 进行异步转同步操作,每个线程退出前必须调用 countDown方法。

使用 CountDownLatch 进行异步转同步操作,每个线程退出前必须调用 countDown方法,线程执行代码注意 catch 异常,确保 countDown 方法被执行到,避免主线程无法执行至 await 方法,直到超时才返回结果。

CountDownLatch 是一个多线程同步工具,它的作用是允许一个或多个线程等待其他线程完成操作。在这里,你想要使用 CountDownLatch 实现异步转同步操作,确保每个线程退出前都调用countDown方法。给个代码示例,演示了如何使用 CountDownLatch 实现这种同步:

import java.util.concurrent.CountDownLatch;

public class AsyncToSyncExample {
    public static void main(String[] args) throws InterruptedException {
        int numThreads = 3; // 假设有3个线程

        // 创建一个 CountDownLatch,计数器初始化为线程数量
        CountDownLatch latch = new CountDownLatch(numThreads);

        // 启动多个线程
        for (int i = 0; i < numThreads; i++) {
            Thread thread = new Thread(() -> {
                try {
                    // 线程执行的业务逻辑
                    doSomeWork();
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    // 无论如何,都需要调用 countDown 方法
                    latch.countDown();
                }
            });
            thread.start();
        }

        // 等待所有线程完成,最多等待5秒(超时时间可以根据实际情况调整)
        if (!latch.await(5000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
            // 超时处理逻辑
            System.out.println("Timeout while waiting for threads to finish.");
        } else {
            // 所有线程执行完成后的逻辑
            System.out.println("All threads have finished their work.");
        }
    }

    private static void doSomeWork() {
        // 模拟线程执行的业务逻辑
        try {
            Thread.sleep(2000);
            System.out.println(Thread.currentThread().getName() + " has finished its work.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  1. 多线程并行处理定时任务时,Timer 运行多个 TimeTask 时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行。

在 Timer 运行多个 TimerTask 时,如果其中一个 TimerTask 抛出了未捕获的异常,将导致整个 Timer 终止,而未抛出异常的任务也将停止执行。这是因为 Timer 的设计导致一个任务的异常会影响到整个 Timer 的执行。代码如下:

import java.util.Timer;
import java.util.TimerTask;

public class TimerTaskExample {
    public static void main(String[] args) {
        Timer timer = new Timer();

        // 任务1,抛出异常
        TimerTask task1 = new TimerTask() {
            @Override
            public void run() {
                System.out.println("Task 1 is running...");
                throw new RuntimeException("Exception in Task 1");
            }
        };

        // 任务2
        TimerTask task2 = new TimerTask() {
            @Override
            public void run() {
                System.out.println("Task 2 is running...");
            }
        };

        // 安排任务1和任务2执行
        timer.schedule(task1, 0, 1000);
        timer.schedule(task2, 0, 1000);
    }
}

使用 ScheduledExecutorService 则没有这个问题:

public class ScheduledExecutorExample {

    public static void main(String[] args) {
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

        // 任务1,每隔2秒执行一次,可能抛出异常
        scheduler.scheduleAtFixedRate(() -> {
            try {
                System.out.println("Task 1 is running...");
                throw new RuntimeException("Exception in Task 1");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, 0, 2, TimeUnit.SECONDS);

        // 任务2,每隔3秒执行一次
        scheduler.scheduleAtFixedRate(() -> {
            try {
                System.out.println("Task 2 is running...");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, 0, 3, TimeUnit.SECONDS);
    }
}
  1. 避免 Random 实例被多线程使用,虽然共享该实例是线程安全的,但会因竞争同一seed 导致的性能下降。

虽然 Random实例的方法是线程安全的,但是当多个线程共享相同的Random 实例并竞争相同的 seed 时,可能会因为竞争而导致性能下降。这是因为 Random 使用一个原子变量来维护其内部状态,当多个线程同时调用 nextInt 等方法时,可能会发生竞争,从而影响性能。

大家可以看下这个例子哈:

import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class SharedRandomPerformanceExample {
    public static void main(String[] args) throws InterruptedException {
        int numThreads = 10;
        int iterations = 1000000;

        // 共享一个 Random 实例
        Random sharedRandom = new Random();

        // 使用多线程执行任务
        ExecutorService executorService = Executors.newFixedThreadPool(numThreads);

        for (int i = 0; i < numThreads; i++) {
            executorService.execute(() -> {
                for (int j = 0; j < iterations; j++) {
                    int randomNumber = sharedRandom.nextInt();
                    // 模拟使用随机数的业务逻辑
                }
            });
        }

        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.MINUTES);
    }
}

在这个例子中,多个线程共享相同的 Random 实例 sharedRandom,并且在循环中调用 nextInt方法。由于 Random 内部使用CAS操作来维护其状态,多个线程可能会竞争同一 seed导致性能下降。

如果你希望避免这种竞争,可以考虑为每个线程创建独立的 Random 实例,以确保每个线程都有自己的状态。在 JDK7 之后,可以直接使用 API ThreadLocalRandom,而在 JDK7 之前,需要编码保证每个线程持有一个实例。

11.并发修改同一记录时,避免更新丢失,需要加锁。

并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用 version作为更新依据。

如果每次访问冲突概率小于20%,推荐使用乐观锁,因为证明并发不是很高。否则使用悲观锁。乐观锁的重试次数不得小于3 次。

  1. 对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。

线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是 A、B、C,否则可能出现死锁。在多线程环境中,当需要对多个资源、数据库表或对象同时加锁时,为了避免死锁,所有线程必须保持一致的加锁顺序。这就是所谓的"锁顺序规范"。

大家有兴趣可以看下这个例子哈,两个线程按照相同的顺序加锁以避免死锁:

public class DeadlockExample {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread 1 acquired lockA");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lockB) {
                    System.out.println("Thread 1 acquired lockB");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            // 保持一致的加锁顺序,先尝试获取 lockA,再获取 lockB
            synchronized (lockA) {
                System.out.println("Thread 2 acquired lockA");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lockB) {
                    System.out.println("Thread 2 acquired lockB");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8