Synchronized的使用和优化

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修饰的:

@Override
public 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 test2
t1 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 = 1
t1 count = 2
t1 count = 3
t1 count = 4
t1 count = 5
Exception 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 = 6
t2 count = 7
t2 count = 8
t2 count = 9
......

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

image-20210923165830441

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

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

输出:

t1 start...
t1 count = 1
t1 count = 2
t1 count = 3
t1 count = 4
t1 count = 5
java.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 = 6
t1 count = 7
t1 count = 8
t1 count = 9
t1 count = 10
t1 count = 11
t1 count = 12
......

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

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
优化是指在多线程编程中,通过改进锁的机制和使用方式来提高程序的性能和并发能力。synchronized关键字是Java中最常用的锁机制之一,它可以保证同一时间只有一个线程可以进入被synchronized修饰的代码块。下面是一些synchronized优化的方法: 1. 减小锁的粒度:如果在一个方法中有多个synchronized代码块,可以考虑将这些代码块拆分成多个方法,以减小锁的粒度。这样可以使得多个线程可以并发执行不同的代码块,提高程序的并发性能。 2. 使用局部变量替代成员变量:在使用synchronized关键字时,尽量使用局部变量而不是成员变量。因为成员变量的访问需要通过对象实例来进行,而局部变量的访问是线程私有的,不需要加锁。 3. 使用同步代码块代替同步方法:在某些情况下,使用同步代码块比使用同步方法更加灵活。同步代码块可以指定锁的粒度,只对需要同步的代码进行加锁,而不是整个方法。 4. 使用volatile关键字:volatile关键字可以保证变量的可见性和禁止指令重排序,可以在一定程度上替代synchronized关键字。但是需要注意,volatile关键字只能保证单个变量的原子性,不能保证多个操作的原子性。 5. 使用Lock接口:Java提供了Lock接口及其实现类ReentrantLock,相比于synchronized关键字,Lock接口提供了更加灵活的锁机制。可以手动控制锁的获取和释放,可以实现公平锁和非公平锁,并且支持多个条件变量。 6. 使用读写锁:如果在多线程环境下,读操作远远多于写操作,可以考虑使用读写锁ReadWriteLock来提高程序的并发性能。读写锁允许多个线程同时读取共享数据,但只允许一个线程写入共享数据。 7. 使用并发集合类:Java提供了一些并发集合类,ConcurrentHashMap、ConcurrentLinkedQueue等,它们内部使用了一些锁优化的技术,可以提高多线程环境下的并发性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

汪了个王

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值