02. 互斥同步(一)——synchronized 关键字


前言

Java 提供了两种锁机制来控制多线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。这两种锁机制都属于互斥锁,即保证同一时刻只有一个线程执行。

本文主要讲解 synchronized 关键字,关于 ReentrantLock 可以查看博主的另一篇文章 互斥同步(二)——ReentrantLock


1. synchronized 关键字

1.1 synchronized 的作用

给某个对象加锁,保证同一时刻最多只有 1 个线程执行被synchronized 修饰的方法或代码块。被修饰的代码块称为同步代码块,被修饰的方法称为同步方法

其它线程必须等待当前线程执行完该方法或代码块之后,才能执行该方法或者代码块。

1.2 synchronized 的使用方法

注意:synchronized 锁定的是对象,不是代码。(锁的对象不能是 String 常量,不能是基础数据类型 Integer、Long 等等)

(1)锁定对象:

示例:

public class T {
    private int count = 10;
    private Object o = new Object();
    
    public void m(){
        synchronized(o){ // 任何线程执行下面的代码,必须先拿到 o 的锁
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}

我们注意到,上面的加锁方式要先 new 一个对象,然后用 synchronized 锁定。我们可以锁定当前对象(this),就不用每次都 new 对象了。

(2)锁定当前对象:

示例:

public class T {
    private int count = 10;

    public void m(){
        // 锁定当前对象
        synchronized(this){ // 任何线程执行下面的代码,必须先拿到 o 的锁
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}

(3)锁定方法:

使用 synchronized (this) 锁定当前对象这种方式就相当于直接给方法加 synchronized 关键字。

public class T {
    private int count = 10;

    // 等同于 synchronized(this)
    public synchronized void m(){
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }
}

(4)锁定静态方法:

静态方法是没有 this 对象的,所以给静态方法加 synchronized 就等同于 synchronized(T.class)。

public class T {
    private static int count = 10;
    
    // 这里等同于 synchronized(T.class)
    public synchronized static void m(){
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }
    
    public static void mm(){
        synchronized (T.class){// 这里不能使用 synchronized(this)
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}

1.3 synchronized 实例测试

做一个 synchronized 的简单测试:

  • 有一个全局变量 count,初始为 10;
  • run() 方法中,先打印 count 的值,再执行 count-- ,最后打印当前调用 run() 方法的线程名称以及减一之后的 count 值;
  • 主线程中写一个 for 循环,创建 5 个线程并启动。

两段代码不同的地方是,一个 run() 方法加了 synchronized 关键字,另一个没有进行线程同步。

测试一:

public class TestSync implements Runnable{

    private int count = 10;

    @Override
    public void run() {
        System.out.print("count = " + count + " ");
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }

    public static void main(String[] args) {
        TestSync t = new TestSync();
        for (int i = 0; i < 5; i++) {
            new Thread(t, "Thread " + i).start();
        }
    }
}

// 测试结果:
count = 10 Thread 0 count = 9
count = 9 count = 9 Thread 2 count = 7
Thread 1 count = 8
count = 7 Thread 3 count = 6
count = 6 Thread 4 count = 5

代码中 run() 方法中有 3 行代码,且没有加锁(没有加 synchronized),那么多个线程可能同时调用 run() 方法。也就是说,第一个线程刚执行完 run() 的前两行代码时,第二个线程也进入 run() 开始执行,这就可能导致 count-- 和之后的打印语句中的 count 对不上号。

测试二:

public class TestSync implements Runnable{

    private int count = 10;

    @Override
    public synchronized void run() {
        System.out.print("count = " + count + " ");
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }

    public static void main(String[] args) {
        TestSync t = new TestSync();
        for (int i = 0; i < 5; i++) {
            new Thread(t, "Thread " + i).start();
        }
    }
}

// 测试结果:
count = 10 Thread 0 count = 9
count = 9 Thread 1 count = 8
count = 8 Thread 2 count = 7
count = 7 Thread 3 count = 6
count = 6 Thread 4 count = 5

给 run() 加锁之后,只有当当前线程执行完 run() 方法之后,另一个线程才可以执行 run() 方法。由测试结果看出,每个线程的 run() 方法里面的内容都是顺序执行的。

2. 几个重要概念

2.1 synchronized 可重入锁

一个同步方法可以调用另外一个同步方法。一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁,也就是说 synchronized 获得的锁是可重入的。

public class TestReentrant {
    synchronized void m1(){
        System.out.println("m1 start");
        try{
            TimeUnit.SECONDS.sleep(1);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        m2();
        System.out.println("m1 end");
    }

    synchronized void m2(){
        System.out.println("m2 start");
        try{
            TimeUnit.SECONDS.sleep(2);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("m2 end");
    }

    public static void main(String[] args) {
        new TestReentrant().m1();
    }
}

// 测试结果:输出 m1 start 之后,睡眠一秒,输出 m2 start,再睡眠两秒,输出 m2 end 和 m1 end
m1 start
m2 start
m2 end
m1 end

注意: synchronized 必须是可重入的,不然会造成死锁。

2.2 异常和锁

程序在执行过程中,如果出现异常,默认情况锁会被释放。所以,在并发处理的过程中,有异常要多加小心,不然可能会发生不一致的情况。

public class TestException {
    public static void main(String[] args) {
        Test t = new Test();

        new Thread(t, "t1").start();
        
        try{
            TimeUnit.SECONDS.sleep(3);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        
        new Thread(t, "t2").start();
    }
}

class Test implements Runnable{
    private int count  = 0;

    @Override
    public void run() {
        m();
    }

    synchronized void m(){
        System.out.println(Thread.currentThread().getName() + " start");
        while(true){
            count++;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
            try{
                TimeUnit.SECONDS.sleep(1);
            }catch(InterruptedException e){
                e.printStackTrace();
            }

            if(count == 5){
            	// 此处会抛出异常,锁将被释放。如果不想被释放,可以在这里进行 catch 捕获异常,然后让循环继续。
                int i = 1 / 0;
                System.out.println(i);
            }
        }
    }
}

// 测试结果:
t1 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 com.zk.sync.Test.m(TestException.java:46)
	at com.zk.sync.Test.run(TestException.java:30)
	at java.lang.Thread.run(Thread.java:745)
t2 start
t2 count = 6
t2 count = 7
t2 count = 8
t2 count = 9
······

示例中,m() 方法内是一个死循环,正常情况下,如果线程 t1 启动后,应该一直执行下去。代码中设置了一个异常(除数不能为 0),当 count = 5 时抛出异常,线程 t1 将锁释放,线程 t2 得到锁后开始执行。

2.3 面试题——同步与非同步方法

面试题:
两个方法,一个加锁(同步方法),另一个没有加锁(非同步方法)。这两个方法能不能同时执行?
答:
能。这两个方法是可以同时调用的,因为第二个方法没有加锁,就是普通的类方法,可以随时执行,不需要等待前一个线程的结束。

测试:

public class TT implements Runnable{
    int b = 100;

    public synchronized void m1() throws Exception{
        b = 1000;
        Thread.sleep(5000);
        System.out.println("b = " + b);
    }

    public void m2(){
        System.out.println(b);
    }

    @Override
    public void run() {
        try{
            m1();
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws Exception{
        TT tt = new TT();
        Thread t = new Thread(tt);
        t.start();
        
        Thread.sleep(1000);
        tt.m2();
    }
}

// 测试结果:
1000
b = 1000

3. 锁优化

锁优化主要是指 JVMsynchronized 的优化。

3.1 锁消除 lock eliminate

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}

我们知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的。但我们看上面这段代码,我们会发现,sb 这个引用只会在 append 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

3.2 锁粗化 lock coarsening

public String test(String str){
       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
           sb.append(str);
           i++;
       }
       return sb.toString():
}

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 循环外),使得这一连串操作只需要加一次锁即可。

3.3 锁细化 lock refinement

	void m3(){
        // do sth need not synchronize
        try{
            TimeUnit.SECONDS.sleep(3);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        
        // 如果业务逻辑中只有下面这句话需要同步,这时不应该给整个方法上锁,只需要给这句话加锁;
        // 采用细粒度的锁,可以使线程争用的时间变短,从而提高效率
        synchronized (this){
            count++;
        }

        // do sth need not synchronize
        try{
            TimeUnit.SECONDS.sleep(3);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
    }

和锁粗化相反,如果一个方法内只有很少的代码需要加锁,那我们只给这些代码加锁即可,而没有必要给整个方法加锁。采用细粒度的锁,可以使线程争用的时间变短,从而提高效率。

3.4 锁升级过程

锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁(自旋锁)、重量级锁,锁升级的过程也是由低到高。

(1)无锁:

无锁是指没有对资源进行锁定,此时所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

(2)偏向锁:

初次执行到 synchronized 代码块的时候,锁对象变成偏向锁,意思是 “偏向于第一个获得它的线程” 的锁。执行完同步代码块的时候,线程不会主动释放偏向锁。当第二次到达同步代码块的时候,线程会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,提高性能。

偏向锁只有遇到其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

(3)轻量级锁(自旋锁):

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

锁竞争: 如果多个线程轮流获取一个锁,但每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用只能等待其释放,这才发生了锁竞争。

自旋: 在轻量级锁状态下继续竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。

忙等: 长时间的自旋操作是非常消耗资源的,一个线程持有锁,其它线程只能在原地空耗 CPU,执行不了任何任务,这种现象叫作忙等(busy-waiting)。synchronized 是轻量级锁,允许短时间的忙等,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

(4)重量级锁:

如果锁竞争情况严重,当自旋次数超过 10 次(默认是 10 次)时,轻量级锁升级为重量级锁。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都处于阻塞状态。这时,所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程状态的变更,这样就会出现频繁的对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

锁的升级过程为:

在没有对资源进行锁定之前,所有的线程都能访问并修改同一个资源,此时资源处于无锁状态——》初次执行到 synchronized 代码块的时候,锁对象升级为偏向锁——》当有其它线程访问时,偏向锁升级为轻量级锁(自旋锁)。其它线程通过自旋获取锁——》自旋超过 10 次,轻量级锁升级为重量级锁。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值