并发编程的12条规范

发布时间:2024年01月15日

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;
????}
}

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

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

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

反例

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();
????????});
????}
}

3. 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

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

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

  • 帮我们管理线程,避免增加创建线程和销毁线程的资源损耗。因为线程其实也是一个对象,创建一个对象,需要经过类加载过程,销毁一个对象,需要走GC垃圾回收流程,都是需要资源开销的。

  • 提高响应速度:如果任务到达了,相对于从线程池拿线程,重新去创建一条新线程执行,速度肯定慢很多。

  • 重复利用:线程用完,再放回池子,可以达到重复利用的效果,节省资源。

反例(显式创建线程):

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);
????????//?执行任务的具体逻辑
????}
}

4. 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);
????????}
????}
}

5. 线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式

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

  • FixedThreadPool?允许的请求队列长度为?Integer.MAX_VALUE,可能会堆积大量的请求,从而导致?OOM

  • CachedThreadPool?:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致?OOM

反例:

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?创建的时候,需要明确配置线程池参数,可以避免资源耗尽风险。

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

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

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

7. 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());
????}
}

解决或规避这个问题的方式可以使用使用ConcurrentHashMap?ConcurrentHashMap?是?HashMap?的线程安全版本,它使用了分段锁(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();
????????}
????}
}

9. 多线程并行处理定时任务时,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);
????}
}

10. 避免 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 次。

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

线程一需要对表?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();
????}
}
文章来源:https://blog.csdn.net/WXF_Sir/article/details/135602973
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。