八、synchronized与ReentrantLock简介

概念

synchronized与ReentrantLock 都是java多线程编程中的重要工具,可以保证编写出的代码线程安全。 之所以存在多线程不安全,是因为一个变量,在多个线程,多个CPU高速缓存的共同作用下,导致与主内存的缓存不一致,从而导致读写结果达不到预期效果。JMM (Java内存模型)规范中规定了 happends-before原则在某些特定情况下默认了操作的可见性,但是这些特定情况以外的情境下则无法保证,所以JVM中提供了 volatile 和 synchronized 关键字,确保了 对变量操作的可见性。

但 同步关键字 并不能适用于所有的编程场景,于是 ReentrantLock 可重入锁 应运而生。

Synchronized 关键字

它可以修饰的有三:

实例方法

这种情况下,锁定的是当前的实例对象,也就是说,只有同一个对象调用这个方法,才会有互斥效果。不同实例之间没有互斥效果。

java 复制代码
// SynchronizedTest类
public class SynchronizedTest {

    public synchronized void test() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "--> " + i);
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


// main类
public class Main {
    public static void main(String[] args) {
        SynchronizedTest s1 = new SynchronizedTest();
        SynchronizedTest s2 = new SynchronizedTest();

        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test();
            }

        });

        Thread t2 = new Thread(new Runnable() {

            @Override
            public void run() {
                s2.test();
            }

        });

        t1.start();
        t2.start();
    }
}

打印结果为:

java 复制代码
Thread-0--> 0
Thread-1--> 0
Thread-0--> 1
Thread-1--> 1
Thread-0--> 2
Thread-1--> 2
Thread-0--> 3
Thread-1--> 3
Thread-0--> 4
Thread-1--> 4
Thread-0--> 5
Thread-1--> 5
Thread-1--> 6
Thread-0--> 6
Thread-0--> 7
Thread-1--> 7
Thread-0--> 8
Thread-1--> 8
Thread-0--> 9
Thread-1--> 9

可以看出,两个线程分别完整打印了0到9。 这是因为他们持有的是两个不同的锁对象(即2个不同的SynchronizedTest实例)而如果他们持有的是同一个锁,那么只有在一个线程释放锁之后,另一个线程才能持有锁。

代码稍作修改:

java 复制代码
// SynchronizedTest类
public class SynchronizedTest {

    public synchronized void test() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "--> " + i);
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


// main类
public class Main {
    public static void main(String[] args) {
        SynchronizedTest s1 = new SynchronizedTest();
        SynchronizedTest s2 = new SynchronizedTest();

        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test();
            }

        });

        Thread t2 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test(); // 都使用s1对象
            }

        });

        t1.start();
        t2.start();
    }
}

那么,打印的结果将会是:

java 复制代码
Thread-0--> 0
Thread-0--> 1
Thread-0--> 2
Thread-0--> 3
Thread-0--> 4
Thread-0--> 5
Thread-0--> 6
Thread-0--> 7
Thread-0--> 8
Thread-0--> 9
Thread-1--> 0
Thread-1--> 1
Thread-1--> 2
Thread-1--> 3
Thread-1--> 4
Thread-1--> 5
Thread-1--> 6
Thread-1--> 7
Thread-1--> 8
Thread-1--> 9

交替打印,符合期望。

静态方法

锁对象为当前的class对象,也就是说,与对象无关了,只要是同一个类,就有互斥效果。

稍作修改,将 test方法改为静态的:

java 复制代码
// SynchronizedTest类
public class SynchronizedTest {

    public static synchronized void test() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "--> " + i);
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


// main类
public class Main {
    public static void main(String[] args) {
        SynchronizedTest s1 = new SynchronizedTest();
        SynchronizedTest s2 = new SynchronizedTest();

        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test();
            }

        });

        Thread t2 = new Thread(new Runnable() {

            @Override
            public void run() {
                s2.test();
            }

        });

        t1.start();
        t2.start();
    }
}

打印结果也是两个线程 ,一个打印完了,另一个才开始打印。

java 复制代码
Thread-0--> 0
Thread-0--> 1
Thread-0--> 2
Thread-0--> 3
Thread-0--> 4
Thread-0--> 5
Thread-0--> 6
Thread-0--> 7
Thread-0--> 8
Thread-0--> 9
Thread-1--> 0
Thread-1--> 1
Thread-1--> 2
Thread-1--> 3
Thread-1--> 4
Thread-1--> 5
Thread-1--> 6
Thread-1--> 7
Thread-1--> 8
Thread-1--> 9

实例代码块

如果修饰的是代码块的话,那么锁对象,就是跟随在 synchronized后面的对象:

java 复制代码
public class SynchronizedTest {

    private static Object lock = new Object();

    public void test() {
        synchronized (lock) {
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + "--> " + i);
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

    }
}

public class Main {
    public static void main(String[] args) {
        SynchronizedTest s1 = new SynchronizedTest();
        SynchronizedTest s2 = new SynchronizedTest();

        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test();
            }

        });

        Thread t2 = new Thread(new Runnable() {

            @Override
            public void run() {
                s2.test();
            }

        });

        t1.start();
        t2.start();
    }
}

上面的代码中,我指定了锁对象为一个静态对象,这种效果就和 直接synchronized修饰方法类似了。效果也一样:

lua 复制代码
```java
Thread-0--> 0
Thread-0--> 1
Thread-0--> 2
Thread-0--> 3
Thread-0--> 4
Thread-0--> 5
Thread-0--> 6
Thread-0--> 7
Thread-0--> 8
Thread-0--> 9
Thread-1--> 0
Thread-1--> 1
Thread-1--> 2
Thread-1--> 3
Thread-1--> 4
Thread-1--> 5
Thread-1--> 6
Thread-1--> 7
Thread-1--> 8
Thread-1--> 9

而如果我不想用静态变量作为锁,又想达到上面同样的效果,那么就可以用 同一个SynchronizedTest来执行test方法:

java 复制代码
public class SynchronizedTest {

    private Object lock = new Object();

    public void test() {
        synchronized (lock) {
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + "--> " + i);
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

    }
}

public class Main {
    public static void main(String[] args) {
        SynchronizedTest s1 = new SynchronizedTest();

        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test();
            }

        });

        Thread t2 = new Thread(new Runnable() {

            @Override
            public void run() {
                s1.test();
            }

        });

        t1.start();
        t2.start();
    }
}

字节码细节

修饰代码块

当synchronized修饰代码块时,比如如下代码:

java 复制代码
public class Foo{
    private int number;
    public void test1(){
        int i = 0;
        synchronized(this){
            number = i+1;
        }
    }
}

javac先编译出他的 class文件,在用javap -c 反编译字节码,看到它的字节码内容如下:

java 复制代码
public class com.example.Foo {
  public com.example.Foo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test1();
    Code:
       0: iconst_0
       1: istore_1
       2: aload_0
       3: dup
       4: astore_2
       5: monitorenter  // 看这里
       6: aload_0
       7: iload_1
       8: iconst_1
       9: iadd
      10: putfield      #2                  // Field number:I
      13: aload_2
      14: monitorexit  // 看这里
      15: goto          23
      18: astore_3
      19: aload_2
      20: monitorexit // 看这里
      21: aload_3
      22: athrow
      23: return
    Exception table:
       from    to  target type
           6    15    18   any
          18    21    18   any
}

上面可以看到 上面的23个字节码指令中, 有1个 monitorenter 和 2个monitorexit 指令。

一个enter 通常对应一个 exit,可是为什么这里多出来一个呢? 这是由于,虚拟机必须保证,在程序发生异常时,锁也能正常释放。

修饰方法时

源代码

java 复制代码
package com.example;

public class Foo {

    private int number;

    public synchronized void test1() {
        int i = 0;
        number = i + 1;
    }
}

字节码:

java 复制代码
public class com.example.Foo {
  public com.example.Foo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public synchronized void test1();
    Code:
       0: iconst_0
       1: istore_1
       2: aload_0
       3: iload_1
       4: iconst_1
       5: iadd
       6: putfield      #2                  // Field number:I
       9: return
}

字节码中,直接在 方法上加上了 synchronized ,这种情况下,没有显式的 monitorenter 和 monitorexit,而是会默认在 方法的开始和结束上分别添加 monitorenter 和 monitorexit。

monitorenter 和 monitorexit

这两个指令都涉及到了 monitor,它可以解释为一把具体的锁,它保存了两个重要的属性, 一个是 计数器,表示当前线程一共访问了几次这个锁 一个是 指针,指向持有当前锁的线程

上图表示了monitor的工作流程,当一个线程执行 enter指令时,monitor的计数器+1,同时,指针指向这个线程。 当一个线程执行 exit时,计数器-1,指针清零,不指向任何线程。

ReentrantLock 可重入锁

基本使用

java 复制代码
package com.example;

import java.util.concurrent.locks.ReentrantLock;

public class Main {

    ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {

        Main main = new Main();

        Thread t1 = new Thread(new Runnable(){

            @Override
            public void run() {
                main.printLog();
            }
            
        });

        Thread t2 = new Thread(new Runnable(){

            @Override
            public void run() {
                main.printLog();
            }
            
        });

        t1.start();
        t2.start();

    }

    private void printLog() {
        try {
            lock.lock();

            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + "->" + i);
            }

        } catch (Exception e) {

        } finally {
            lock.unlock();
        }
    }
}

打印结果:

java 复制代码
Thread-0->0
Thread-0->1
Thread-0->2
Thread-0->3
Thread-0->4
Thread-1->0
Thread-1->1
Thread-1->2
Thread-1->3
Thread-1->4

第一个线程持有了所,在执行完循环打印之后才释放锁,然后才轮到第二个线程来持有。也能达到与 synchronize一样的效果。

但是注意一个细节: 释放锁的操作, 放在了 try 代码块的 finally中 ,这是为了在程序发生异常时也能正常释放锁

公平锁

以下是 ReentrantLock的部分源代码:

java 复制代码
    /**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

可以看到有个构造函数的重载,其中有一个带入参:fair,我们用默认的无参构造函数会创建出一个非公平锁,如果传入true作为参数,那么就是创建出一个公平锁。

如下代码:

java 复制代码
package com.example;

import java.util.concurrent.locks.ReentrantLock;

public class Main {

    private int shareInt = 0;

    ReentrantLock lock = new ReentrantLock(true);

    private void printLog() {

        while (shareInt < 20) {
            lock.lock();
            try {

                shareInt++;
                System.out.println(Thread.currentThread().getName() + "->" + (shareInt));

            } catch (Exception e) {

            } finally {
                lock.unlock();
            }
        }

    }

    public static void main(String[] args) {

        Main main = new Main();

        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                main.printLog();
            }

        });

        Thread t2 = new Thread(new Runnable() {

            @Override
            public void run() {
                main.printLog();
            }

        });

        Thread t3 = new Thread(new Runnable() {

            @Override
            public void run() {
                main.printLog();
            }

        });

        t1.start();
        t2.start();
        t3.start();

    }

}

公平锁的效果为:顾名思义,对于每一个线程,这把锁都是公平对待的。在多个线程请求锁的时候,按照他们发出请求的顺序来获得锁,公平锁会维护一个等待队列,新的线层会按照排队顺序来进行(获得锁,执行代码,释放锁)3个过程。

与之相对的就是默认的非公平锁,不会按照线程请求的顺序来获得锁,非公平锁可以通过插队机制,允许后来先到,从而提高整体的吞吐量。

两者的区别是,公平锁可以保证了请求锁的顺序和获得锁的顺序相同,带来的不良后果是,CPU会在多个线程中来回切换,算是牺牲了性能。非公平锁则没有排队机制,减少了 CPU来回切换,但是有可能导致某些线程长时间等到。 总之这两种锁,用于两种不同的业务场景,各有千秋。

读写锁

业务开发中,经常遇到 多个线程共享一个用于缓存的数据结构,比如 一个可能会随着app运行而膨胀的map,某些操作会往这个map中添加一个键值对,另一些操作则只会去从map中获取某一个key对应的value。

并且大部分情况下,是读的操作多,写的操作少。这种情况下,就要求 写的操作一定要对读的操作可见。

这是典型的生产消费者模型,消费必须以已经存在的产品作为前提,如果没有产品了,消费就会等待。

ReentrantLock提供了一个读写锁可以解决此场景下的代码写法。 如果没有这个读写锁,我们的写法可能是利用 线程的wait和notify(等待通知机制)来手动控制。

但是今天有了读写锁,则可以优化成以下写法:

java 复制代码
package com.example;

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Main {

    private static int shareInt = 0;

    static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    static class Reader implements Runnable {

        @Override
        public void run() {

            for (int i = 0; i < 10; i++) {
                ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
                try {
                    readLock.lock();
                    System.out.println(Thread.currentThread().getName() + " - " + shareInt);
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    readLock.unlock();
                }
            }

        }

    }

    static class Writer implements Runnable {

        @Override
        public void run() {
            for (int i = 0; i < 7; i += 2) {
                ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
                try {
                    writeLock.lock();
                    System.out.println(Thread.currentThread().getName() + " -正在写入 " + shareInt);
                    Thread.sleep(200);
                    shareInt += 2;

                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    writeLock.unlock();
                }
            }
        }

    }

    public static void main(String[] args) {

        Thread t1 = new Thread(new Reader(), "读线程1");
        Thread t2 = new Thread(new Reader(), "读线程2");
        Thread t3 = new Thread(new Writer(), "写线程");

        t1.start();
        t3.start();
        t2.start();

    }

}

注意几个细节:

  • shareInt为模拟的共享数据,模拟的是 必须等写入完成之后,读的操作才能继续
  • 创建了2个读线程和1个写线程
  • 无论是读还是写,都要先获取对应的锁,操作完成之后再释放锁

运行的结果为:

java 复制代码
读线程1 - 0
读线程2 - 0
写线程 -正在写入 0
写线程 -正在写入 2
写线程 -正在写入 4
写线程 -正在写入 6
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8
读线程2 - 8
读线程1 - 8

可以看到,当写操作在进行时,写操作都在等待,直到写操作释放了写锁,这样就保证了 后续的读操作都是取的最新的共享变量的值。

总结

Java中两种保证线程同步的方式,synchronize 、 ReentrantLock 都能保证线程安全。

前者 由JVM自动完成加锁和释放锁的操作,而后者的加锁释放锁都需要开发者手动编码,显然后者在复杂的业务场景中更加实用,公平锁和读写锁都各自解决了一部分典型的应用场景问题。

相关推荐
天下无贼!1 小时前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr1 小时前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林1 小时前
npm发布插件超级简单版
前端·npm·node.js
罔闻_spider2 小时前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔2 小时前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠2 小时前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
盏灯2 小时前
前端开发,场景题:讲一下如何实现 ✍电子签名、🎨你画我猜?
前端
WeiShuai2 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
Wandra2 小时前
很全但是超级易懂的border-radius讲解,让你快速回忆和上手
前端
ice___Cpu2 小时前
Linux 基本使用和 web 程序部署 ( 8000 字 Linux 入门 )
linux·运维·前端