写点什么

synchronized 的使用及优化

作者:Ayue、
  • 2021 年 12 月 23 日
  • 本文字数:5658 字

    阅读完需:约 19 分钟

Java 中每一个对象都可以作为锁,这是 synchronized 实现同步的基础:


  1. 普通同步方法,锁的是当前实例对象;

  2. 静态同步方法,锁的是当前类的 class 对象;

  3. 同步方法块,锁的是括号里面的对象。

场景 1、锁对象的改变

锁定某对象 o,如果 o 的属性发生改变,不影响锁的使用,但是如果 o 变成另外一个对象,则锁定的对象发生改变,应该避免将锁定对象的引用变成另外一个对象。


public class Sync1 {
Object o = new Object();
public void sync() { synchronized (o) { //t1拿到锁 在这里无限执行,并没有走出同步代码块 while (true) { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("当前线程" + Thread.currentThread().getName()); } } }
public static void main(String[] args) { Sync1 sync1 = new Sync1(); Thread t1 = new Thread(sync1::sync, "t1"); t1.start(); Thread t2 = new Thread(sync1::sync, "t2"); t2.start(); }}
输出:当前线程t1当前线程t1当前线程t1当前线程t1......
复制代码


如果改变对象 o,则 2 个线程交替执行。


public static void main(String[] args) {    Sync1 sync1 = new Sync1();    Thread t1 = new Thread(sync1::sync, "t1");    t1.start();    Thread t2 = new Thread(sync1::sync, "t2");    //改变对象 o    sync1.o = new Object();    t2.start();}
输出:当前线程t1当前线程t2当前线程t1当前线程t2当前线程t2当前线程t1......
复制代码

场景 2、字符串作为锁定对象

不要以字符串常量作为锁定的对象


public class Sync2 {
String lock1 = "lock"; String lock2 = "lock";
public void sync1() { synchronized (s1) { //t1 在这里无限执行 while (true) { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("当前线程" + Thread.currentThread().getName()); } } } public void sync2() { synchronized (s2) { System.out.println("当前线程" + Thread.currentThread().getName()); } }
public static void main(String[] args) { Sync2 sync1 = new Sync2(); Thread t1 = new Thread(sync1::sync1, "t1"); t1.start(); Thread t2 = new Thread(sync1::sync2, "t2"); t2.start(); }}
输出:当前线程t1当前线程t1当前线程t1当前线程t1......
复制代码


可以看到线程 1 和 2 分别锁的是 lock1 和 lock2,而执行结果确还是被线程 1 阻塞,虽然表面上看并不是同一个对象,但实际上我们知道在 JVM 堆中的常量池中只有一个字面量"lock",即lock1 == lock2 = true


因此,在实际开发中我们无法保证别人也用到相同字面量的锁,一旦相同,后果就非常严重了。

场景 3、减小锁的粒度

什么是锁的粒度呢?所谓锁的粒度就是你要锁住的范围是多大。


比如你在家上卫生间,你只要锁住卫生间就可以了,不需要将整个家都锁起来不让家人进门吧,卫生间就是你的加锁粒度。


怎样才算合理的加锁粒度呢?


其实卫生间并不只是用来上厕所的,还可以洗澡,洗手。这里就涉及到优化加锁粒度的问题。


你在卫生间里洗澡,其实别人也可以同时去里面洗手,只要做到隔离起来就可以,如果马桶,浴缸,洗漱台都是隔开相对独立的,实际上卫生间可以同时给三个人使用,当然三个人做的事儿不能一样。这样就细化了加锁粒度,你在洗澡的时候只要关上浴室的门,别人还是可以进去洗手的。如果当初设计卫生间的时候没有将不同的功能区域划分隔离开,就不能实现卫生间资源的最大化使用。


比较test1test2,业务逻辑中只有count++这句需要同步,这时不应该给整个方法上锁采用细粒度的锁,同步代码快中的语句越少越好,可以使线程争用时间变短,从而提高效率。


public class Sync3 {
int count = 0;
public synchronized void test1() { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
count++;
try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } }

/** * 局部加锁 */ public void test2() { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized (this) { count++; }
try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } }}
复制代码

场景 4、锁粗化

在代码中,需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。这也是很多人原因是用同步代码块来代替同步方法的原因,因为往往他的粒度会更小一些,就和上面讲的一样。


但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。


就好比你去银行办业务,你为了减少每次办理业务的时间,你把要办的三个业务分成三次去办理,这反而适得其反了。因为这平白的增加了很多你重新取号、排队、被唤醒的时间。


public void doSomething() {    synchronized (lock) {        //业务1    }    //do other some thing    synchronized (lock) {        //业务2    }    synchronized (lock) {        //业务3    }}
复制代码


实际上,一个柜台是可以处理多个业务的


public void doSomething() {    synchronized (lock) {        //业务1        //do other some thing        //业务2        //业务3    }}
复制代码


另一种需要锁粗化的极端的情况是:加锁操作是出现在循环体中


for(int i=0;i<100000;i++){      synchronized(this){          do();      }   }  
复制代码


上面代码每次循环都会进行锁的请求、同步与释放,看起来貌似没什么问题,且在 jdk 内部会对这类代码锁的请求做一些优化,但是还不如把加锁代码写在循环体的外面,这样一次锁的请求就可以达到我们的要求,除非有特殊的需要:循环需要花很长时间,但其它线程等不起,要给它们执行的机会。


锁粗化后的代码如下:


synchronized(this){      for(int i=0;i<100000;i++){          do();     }}  
复制代码

场景 5、锁消除

锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。


怎么理解?比如方法内局部申明锁对象:


public void sync() {    Object o = new Object();    synchronized (o){        do();    }}
复制代码


在动态编译同步块的时候,JIT 编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。


如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。


当然,这种情况我们都是能看出来的,也不会这么写,万一写了这样的代码那只能 kill 一个程序猿祭天了。


另外,我们知道StringBuffer经常用来拼接字符串,而且append()方法是线程安全的,查看源码可以看到该方法是通过synchronized修饰的:


@Overridepublic synchronized StringBuffer append(String str) {    toStringCache = null;    super.append(str);    return this;}
复制代码


如果我们在线程内部把StringBuffer当作局部变量使用:


for (int i = 0; i < 10000; i++) {    StringBuffer str = new StringBuffer();    str.append("Java");    str.append("tv");}
复制代码


如果你没看过append()方法的源码,也不知道啊,所以在这种情况下,JIT 就可以帮忙优化,进行锁消除。

场景 6、同步方法和非同步方法同时调用

执行一个同步方法,在没有释放锁的情况下,不影响其他线程执行非同步方法(就算他是一个同步方法,如果锁的不是同一个对象也不影响)。


public class Sync {

public synchronized void test1() { System.out.println(Thread.currentThread().getName() + " test1 start..."); try { //睡眠5s 由于还要t2要执行 cpu回去执行t2 Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " test1 end..."); }
public void test2() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " test2"); }
public static void main(String[] args) { Sync sync = new Sync(); //正在执行一个同步方法 没有释放锁 new Thread(sync::test1, "t1").start(); //不影响其他线程执行非同步方法(就算他是一个同步方法,如果锁的不是同一个对象也不影响) new Thread(sync::test2, "t2").start(); }}
输出:t1 test1 start...t2 test2t1 test1 end...
复制代码

场景 7、锁重入

一个同步方法调用另外一个同步方法,是可以获取到锁的,synchronized默认支持重入。


synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。


可重入可以避免一些死锁的情况,也可以让我们更好封装我们的代码。


public class Sync {
synchronized void test1() { System.out.println("test1 start..."); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } test2(); }
synchronized void test2() { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("test2 start..."); }

public static void main(String[] args) { Sync sync = new Sync(); sync.test1(); }}
输出:test1 start...test2 start...
复制代码


这里要注意test2为什么也需要加synchronized


是因为你无法保证别的线程来单独调用test2

场景 8、synchronized 和 exception

synchronized 锁定一段代码之后,如果在同步代码块中遇到异常,会自动释放锁。


public class Sync {    Object o = new Object();
int count = 0;
void test() { synchronized (o) { //t1进入并且启动 System.out.println(Thread.currentThread().getName() + " start..."); //t1 会死循环 t1 讲道理不会释放锁 while (true) { count++; System.out.println(Thread.currentThread().getName() + " count = " + count); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } //加5次之后 发生异常 if (count == 5) { int i = 1 / 0; } } } }
public static void main(String[] args) { Sync demo11 = new Sync(); new Thread(() -> { demo11.test(); }, "t1").start(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(() -> { demo11.test(); }, "t2").start(); }}
复制代码


输出:


t1 count = 1t1 count = 2t1 count = 3t1 count = 4t1 count = 5Exception in thread "t1" java.lang.ArithmeticException: / by zero  at cn.javatv.sync.used.demo8.Sync.test(Sync.java:31)  at cn.javatv.sync.used.demo8.Sync.lambda$main$0(Sync.java:40)  at java.lang.Thread.run(Thread.java:745)t2 start...t2 count = 6t2 count = 7t2 count = 8t2 count = 9......
复制代码


可以看到抛出异常后会释放锁,这是synchronized 的机制,在遇到异常后会gotomonitorexit



需要注意的是,如果异常被try catch那么是不会释放锁的,把上面的代码改动一下:


try {    if (count == 5) {        int i = 1 / 0;    }} catch (Exception e) {    e.printStackTrace();}
复制代码


输出:


t1 start...t1 count = 1t1 count = 2t1 count = 3t1 count = 4t1 count = 5java.lang.ArithmeticException: / by zero  at cn.javatv.sync.used.demo8.Sync.test(Sync.java:32)  at cn.javatv.sync.used.demo8.Sync.lambda$main$0(Sync.java:44)  at java.lang.Thread.run(Thread.java:745)t1 count = 6t1 count = 7t1 count = 8t1 count = 9t1 count = 10t1 count = 11t1 count = 12......
复制代码


可以看到一直是t1获取到锁。

发布于: 2 小时前阅读数: 6
用户头像

Ayue、

关注

个人站点:javatv.net 2019.10.16 加入

学习知识,目光坚毅

评论

发布
暂无评论
synchronized的使用及优化