方法上加上 synchronized 就可以了么
我们先来看一段代码
public static void main(String[] args) { DemoController demoController = new DemoController(); new Thread(() -> demoController.sum()).start(); new Thread(() -> demoController.isEqual()).start(); } private volatile int counter1 = 0; private volatile int counter2 = 0; public void sum () { IntStream.rangeClosed(0, 1000).boxed().forEach(i -> { counter1 += i; counter2 += i; }); } public void isEqual () { IntStream.rangeClosed(0, 1000).boxed().forEach(i -> { if (counter1 != counter2) { System.out.println("counter1 != counter2 counter1 = " + counter1 + " counter2 = " + counter2); } }); }
代码中定义两个变量 counter1 和 counter2 , 方法 sum 对两个变量循环 1000 次进行求和;
方法 isEqual 对这两个变量进行测试看是否一直相等。如果一直相等,程序结束后是不会有输出的。从代码上来看, 我们在 sum 方法中同时对 counter1 和 counter2 进行加和, 它们应该说是一直相等的。 但是运行之后,我们会发现程序结束后有很多次都出现了 counter1 和 counter2 不相等的情况。
对多线程敏感的朋友其实一眼就看到了,说,你这是多线程操作,需要保证多个操作换原子性,这样才能保证 counter1 和 counter2 时刻相等。
那这个代码要怎么改造呢?
既然 sum 方法不是原子性操作,那我们给 sum 方法无脑加上 synchronized 不就好了么?
public synchronized void sum () { IntStream.rangeClosed(0, 1000).boxed().forEach(i -> { counter1 += i; counter2 += i; }); }
这样可以解决问题么? 我们运行看一下:
没有内容输出,好像真的可以!别急,我们多运行几次程序
是的,高兴早了,并没有解决问题。
那为什么呢,我们在 sum 方法前面加上了 synchronized 关键字,保证了 sum 方法的操作原子性, 为啥不能解决这个问题呢?回过头来,我们再看看代码,会发现 sum 和 isEqual 方法运行在两个不同的线程中,thread1 运行的 sum 方法, thread2 运行的 isEqual 方法, 我们只保证 sum 方法的原子性是行的,因为 sum 方法至始至终都只运行在 thread1 中,并没有产生多线程的并发问题。产生并发问题的是 sum 方法和 isEqual 方法之间。所以要想解决这个问题应该是在 sum 和 isEqual 方法都加上 synchronized 关键字
public synchronized void sum () { IntStream.rangeClosed(0, 1000).boxed().forEach(i -> { counter1 += i; counter2 += i; }); } public synchronized void isEqual () { IntStream.rangeClosed(0, 1000).boxed().forEach(i -> { if (counter1 != counter2) { System.out.println("counter1 != counter2 counter1 = " + counter1 + " counter2 = " + counter2); } }); }
运行一下看看结果
这样看起来才是真正把这个问题给解决了。
我们回过头来看看为什么会出现这种误区:在没有保证原子性的地方加上 synchronized 关键字保证原子性即可。
- 需要明确谁是共享变量
- 共享变量在哪里出现了资源的竞争
- 在哪里出现资源的竞争才需要在哪里加锁
上面的例子中, 共享变量就是 counter1 和 counter2 ;counter1 和 counter2 在 执行 sum 和 isEqual 时才出现了资源的竞争;所以我们要在 sum 和 isEqual 中加上 synchronized
为什么锁不住
再来看一个例子
public static void main(String[] args) { IntStream.rangeClosed(1, 10000).boxed().parallel().forEach(i -> new DemoController().add()); System.out.println(DemoController.getA()); } private static int a; public synchronized void add() { a++; } public static int getA() { return a; }
这个例子中, 一个共享变量 a, 一个方法 add 每次 +1 ,现在10000个并发对 a 变量进行加1 操作,最后输出 a 的值。add 方法前我们加了 synchronized ,按照预期来讲,我们 getA() 的时候应该得到的是 10000, 但真的是这样么?我们运行一下:
很遗憾,结果并不是我们想要的。为什么会这样呢?
我们也严格按照前面讲的来做了呀:明确了 a 是共享变量;资源在 add 方法中出现了竞争;在 add 方法上加了 synchronized。
这是因为我们还要搞清楚一件事:我们加锁,锁的是谁
在这个例子中,变量 a 是静态的,它的锁级别应该是类级别的, 而 add 方法不静态方法, 在前面加上 synchronized ,锁的只是DemoController 类的某个实例,所以会出现明明加了 synchronized 还是出现并发问题。
既然知道了问题的所在,那就很好解决了。我们只需要把锁加在类级别上就可以了。
private static int a; private static Object locker = new Object(); public void add() { synchronized (locker) { a++; } }
定义一个静态的 Object 对象用来当作锁的标志量,synchronized 锁的是 locker 这个标志量,那么这样这个锁就是在类级别了。我们再来验证一下
哪哪都是 synchronized
方法上加 synchronized 关键字实现加锁确实很爽,那假如涉及到共享变量的方法都加了 synchronized,这个系统会变成什么样呢?想想都可怕。 这种不管三七二十一的 加上 synchronized 的做法有以下几个坏处:
- 真的没必要!通常情况我们的系统都是MVC三层架构,数据经过无状态的 Controller、Service、DAO 流转到DB,没必要使用 synchronized 来保护什么共享资源数据。
- 极大地降低性能!使用 Spring 框架时,默认情况下 Controller、Service、Repository 是单例的,加上 synchronized 会导致整个程序几乎就只能支持单线程,造成极大的性能问题,并发量大大降低。
那如果我们确实有一些共享数据需要保护,怎么办呢?
那我们需要要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。
比如前段时间,有一个业务场景:有一个线程共享的 List, 里面存的是当前活跃的连接信息。
List 又有一段比较耗时的操作,但是不涉及到线程安全问题,那么我们应该如何加锁呢?
private List<Integer> list = new ArrayList<>(); //不涉及共享资源的慢方法 private void compute() { try { TimeUnit.MILLISECONDS.sleep(10); } catch (InterruptedException e) { } } @GetMapping("long") public void lon9() { long start = System.currentTimeMillis(); IntStream.rangeClosed(0, 1000).boxed().parallel().forEach(i -> { synchronized (this) { compute(); list.add(i); } }); log.info("花费时间为: {}", (System.currentTimeMillis() - start) / 1000.0); } @GetMapping("short") public void sh0rt() { long start = System.currentTimeMillis(); IntStream.rangeClosed(0, 1000).boxed().parallel().forEach(i -> { compute(); synchronized (this) { list.add(i); } }); log.info("花费时间为: {}", (System.currentTimeMillis() - start) / 1000.0); }
上面代码中,有一个共享资源 list, 对 list 会进行比较耗时的计算操作,另一个是模拟多个请求,long 接口 synchronized 的范围比较大, 运行结果可以看到, 耗时有 11s+ , short 接口synchronized 只进行了必要地方的进行括起来,耗时只有 1s+ 。
从这个例子中我们可以看到,使用 synchronized 对系统的性能影响是很大的。业务中真的要用到的地方一定要减小粒度。
那么问题来了,锁范围已经不能再缩小了,性能还无法满足需求的话,我们就要考虑另一个的粒度问题了。
即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。
常见的业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我就只说几个大概的结论。然后根据自己的需求来考虑是否有必要进一步优化:
- 对于读写比例差异明显的场景,考虑使用 ReentrantReadWriteLock 细化区分读写锁,来提高性能。
- JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平锁的版本,在没有特殊的情况下不要轻易开启公平锁特性,在任务不重的情况下开启公平锁可能会让性能指数级下降