synchronized用法详解

2年前 (2022) 程序员胖胖胖虎阿
195 0 0

目录

1.线程安全问题

2.使用同步机制管理共享数据

3.synchronized原理概述

3.1 同步代码块的实现原理

3.2 同步方法的原理

4.synchronized的使用

4.1同步代码块

  4.2同步普通方法

4.3同步静态方法

5.synchronized的不可中断性与可重入性

5.1 不可中断性

5.2 可重入性

6.使用synchronized的注意事项

6.1注意synchronized同步块的粒度

 6.2 对 String 加锁

 6.3 对Integer、Long、Short等包装类加锁


1.线程安全问题

       线程允许程序控制流的多重分支同时存在于一个进程内,它们共享进程范围内的资源,比如内存和文件句柄,但每一个线程都有其自己的程序计数器、栈和本地变量。线程也称为是轻量级的进程,因为线程共享其所属进程的内存地址空间,因此同一进程内的所有线程访问相同的变量,从同一个堆中分配对象,这相对于进程间通信来说实现了良好的数据共享。这是多线程的好处。

       但是,因为线程共享相同的内存地址空间,且并发地运行,它们可能访问或修改其他线程正在使用的变量,当数据意外改变时,如果没有明确的同步来管理共享数据,可能会造成混乱,从而产生意外的结果,引发线程安全问题。造成线程安全问题的原因归结为:

  • 存在共享数据(也称临界资源);
  • 存在多条线程共同操作这些共享数据。

       比如一个简单的场景:取钱。用户输入取款金额,然后系统判断账户余额是否足够,如果足够则取款成功,否则取款失败。

       1、Account账户类,提供draw方法来完成取款操作

/**
 * @author yedashi
 * @version 1.0
 * @date 2022/5/13 9:34
 * @description
 */
public class Account {

    //  账户编号
    private String accountNo;

    // 余额
    private double balance;

    public Account() {}

    public Account(String accountNo, double balance) {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    public String getAccountNo() {
        return accountNo;
    }

    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }

    // 余额不能随便修改,所以只提供get方法
    public double getBalance() {
        return balance;
    }

    // 提供一个raw方法来完成取钱操作
    public void draw(double drawAccount) {
        //  账户余额大于所取的钱数
        if(balance >= drawAccount) {
            // 吐出钞票
            System.out.println(Thread.currentThread().getName() + " 取钱成功!吐出钞票:" + drawAccount);
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改余额
            balance -= drawAccount;
            System.out.println("余额为:" + balance);
        } else {
            System.out.println(Thread.currentThread().getName() + " 取钱失败!余额不足!");
        }
    }
}

      2、取钱线程类,模拟取款操作:

/**
 * @author yedashi
 * @version 1.0
 * @date 2022/5/13 9:41
 * @description
 */
public class DrawThread extends Thread {

    private Account account;
    // 当前线程所希望取到的钱数
    private double drawAccount;

    public DrawThread(String name, Account account, double drawAccount) {
        super(name);
        this.account = account;
        this.drawAccount = drawAccount;
    }

    @Override
    public void run() {
        account.draw(drawAccount);
    }
}

      3、测试,同时启动两个线程,对同一个账户进行取款操作:

package com.stone.crazy.java.ch16.se05;

/**
 * @author yedashi
 * @version 1.0
 * @date 2022/4/19 9:03
 * @description
 */
public class DrawTest {

    public static void main(String[] args) {
        Account account = new Account("1234567", 1000);
        new DrawThread("甲", account, 800).start();
        new DrawThread("乙", account, 800).start();
    }
}

      测试结果 如下:

synchronized用法详解

       从结果得知,账户余额为1000,却总共取出了1600,明显错误了(多次运行,会得到不同的结果,甲乙两个线程取款的顺利也可能不同)。

2.使用同步机制管理共享数据

       针对上面的问题,Java提供了同步机制来协调多线程对共享数据的访问。对于Account类,只需要把draw方法声明为synchronized来保证线程的安全。

/**
 * @author yedashi
 * @version 1.0
 * @date 2022/5/13 9:34
 * @description
 */
public class Account {

    //  账户编号
    private String accountNo;

    // 余额
    private double balance;

    public Account() {}

    public Account(String accountNo, double balance) {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    public String getAccountNo() {
        return accountNo;
    }

    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }

    // 余额不能随便修改,所以只提供get方法
    public double getBalance() {
        return balance;
    }

    // 提供一个raw方法来完成取钱操作
    public synchronized void draw(double drawAccount) {
        //  账户余额大于所取的钱数
        if(balance >= drawAccount) {
            // 吐出钞票
            System.out.println(Thread.currentThread().getName() + " 取钱成功!吐出钞票:" + drawAccount);
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改余额
            balance -= drawAccount;
            System.out.println("余额为:" + balance);
        } else {
            System.out.println(Thread.currentThread().getName() + " 取钱失败!余额不足!");
        }
    }
}

      再次进行测试,结果如下:

synchronized用法详解

       无论运行多少次,结果都一样正确。对比上面的结果,也可以发现,线程按顺利访问draw方法,按照代码的先后顺序执行,一个线程执行完draw方法后,另一个线程才接着执行draw方法后,这样可以保证多个线程不会同时访问同一个共享数据。

3.synchronized原理概述

        synchronized是Java的一个关键字。来自官方的解释:Synchronized方法支持一种简单的策略,用于防止线程干扰和内存一致性错误:如果一个对象对多个线程可见,则对该对象变量的所有读或写操作都通过Synchronized方法完成。

       Synchronized保证同一时刻有且只有一条线程在操作共享数据,其他线程必须等待该线程处理完数据后再对共享数据进行操作。此时便产生了互斥锁,互斥锁的特性如下:

  • 互斥性:即在同一时刻只允许一个线程持有某个对象锁,通过这种特性来实现多线程协调机制,这样在同一时刻只有一个线程对所需要的同步的代码块(复合操作)进行访问。互斥性也成为了操作的原子性。
  • 可见性:必须确保在锁释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程可见(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起数据不一致。

        Synchronized是最基本的互斥手段,保证同一时刻最多只有1个线程执行被Synchronized修饰的方法 / 代码,其他线程 必须等待当前线程执行完该方法 / 代码块后才能执行该方法 / 代码块。

3.1 同步代码块的实现原理

        Synchronized是由JVM实现的一种实现互斥同步的一种方式,Synchronized同步代码块时,如果查看编译后的字节码,会发现,被Synchronized修饰过的程序块,在编译前后被编译器生成了monitorenter和monitorexit两个字节码指令,其中,monitorexit指令出现了两次。monitorenter指向同步代码块的开始位置,monitorexit指明同步代码块的结束位置。如图:

synchronized用法详解

        先看monitorenter指令。每个对象都是一个监视器锁(monitor)(不加 synchronized 的对象不会关联监视器),在虛拟机执行到monitorenter指令时,首先要尝试获取对象的锁,获取monitor的所有权:

       (1)如果monitor的进入数为0,表示这个对象没有被锁定,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
       (2)如果线程已经占有该monitor,说明当前线程已经拥有了这个对象的锁,只是重新进入,则进入monitor的进入数加1;
       (3)如果其他线程已经占用了monitor,则获取monitor的所有权失败,该线程进入阻塞状态等待,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

        执行monitorexit指令的线程必须是对象锁所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者锁,就被释放了其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
       monitorexit指令出现了两次,第1次为同步正常退出释放锁,第2次为发生异步退出释放锁,也就是说获得锁的线程可以通过正常控制路径退出,或者在同步代码块中抛出异常来释放锁。

3.2 同步方法的原理

        当Synchronize同步一个方法(既可以是普通方法,也可以是静态方法)时,通过反编译查看,被Synchronized修饰过的方法,在编译后这里面并没monitorenter和monitorexit,相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符,如下图所示:

synchronized用法详解

        这其实是一种隐式的方式,JVM就是根据 “ACC_SYNCHRONIZED” 标示符来实现方法的同步的。

        “ACC_SYNCHRONIZED”标志用来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED是否被设置,如果被设置,当前线程将会获取monitor,获取成功后才执行方法体,最后不管方法是否正常完成都会释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
        两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

4.synchronized的使用

4.1同步代码块

       使用Synchronize修饰代码块时,其作用域是被修饰的整个代码块里面的内容,作用对象是括号中的对象,这个作用对象可以是类,也可以是指定的对象。

       1、作用对象是类的时候,作用的是类及该类的所有对象

/**
 * @author yedashi
 * @version 1.0
 * @date 2022/5/16 11:41
 * @description
 */
public class SynchronizedUsage {

    private void print() {
        String name = Thread.currentThread().getName();
        for (int i = 0; i < 5; i++) {
            System.out.println("Hello " + name);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 同步代码块——锁的对象是类
     */
    public void synchronizedCodelockClass(){
        // 这里锁住的是类
        synchronized (SynchronizedUsage.class){
            print();
        }
    }

}

测试类ThreadTest:

package com.stone.crazy.java.ch16.se05.syn;

/**
 * @author yedashi
 * @version 1.0
 * @date 2022/5/16 11:34
 * @description
 */
public class ThreadTest {

    /**
     * 同步代码块——类锁测试
     */
    public void testSynchronizedCodelockClass() {
        SynchronizedUsage s1 = new SynchronizedUsage();
        SynchronizedUsage s2 = new SynchronizedUsage();
        Thread thread1 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s1.synchronizedCodelockClass();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread1.setName("甲线程");
        thread1.start();

        Thread thread2 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s2.synchronizedCodelockClass();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread2.setName("乙线程");
        thread2.start();
    }

    public static void main(String[] args) {
        ThreadTest test = new ThreadTest();
        test.testSynchronizedCodelockClass();
    }
}

测试结果如下:

甲线程启动
Hello 甲线程
乙线程启动
Hello 甲线程
Hello 甲线程
Hello 甲线程
Hello 甲线程
Hello 乙线程
甲线程结束
Hello 乙线程
Hello 乙线程
Hello 乙线程
Hello 乙线程
乙线程结束

        可见,当锁住的是类的时候,虽然多个线程所关联的对象不一样,但这些对象同属SynchronizedUsage,锁住的代码块只能在当前已获得锁的线程执行完毕之后,才能由下一个线程去获得锁然后执行代码块。

        2、作用对象为对象

        在SynchronizedUsage中添加2个方法,一个锁住当前对象,另一个锁住其他对象

    /**
     * 同步代码块——锁的对象是对象实例
     */
    public void synchronizedCodelockObjct() {
        // 这里锁住的是当前对象
        synchronized(this) {
            print();
        }
    }

    /**
     * 同步代码块——锁的对象是对象实例
     */
    public void synchronizedCodelockObjct2() {
        Object obj = new Object();
        // 这里锁住的是当前对象
        synchronized(obj) {
            System.out.println("锁的是object对象");
            print();
        }
    }

         测试类增加一个测试方法testSynchronizedCodelockObjct:

    public void testSynchronizedCodelockObjct() {
        SynchronizedUsage s3 = new SynchronizedUsage();
        SynchronizedUsage s4 = new SynchronizedUsage();

        Thread thread3 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s3.synchronizedCodelockObjct();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread3.setName("丙线程");
        thread3.start();

        Thread thread4 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s3.synchronizedCodelockObjct();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread4.setName("丁线程");
        thread4.start();

        Thread thread5 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s4.synchronizedCodelockObjct();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread5.setName("戊线程");
        thread5.start();

        Thread thread6 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s3.synchronizedCodelockObjct2();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread6.setName("己线程");
        thread6.start();
    }


    public static void main(String[] args) {
        ThreadTest test = new ThreadTest();
        //test.testSynchronizedCodelockClass();
        test.testSynchronizedCodelockObjct();
    }

        测试结果如下:

丙线程启动
Hello 丙线程
丁线程启动
戊线程启动
己线程启动
锁的是object对象
Hello 己线程
Hello 戊线程
Hello 丙线程
Hello 己线程
Hello 戊线程
Hello 丙线程
Hello 戊线程
Hello 己线程
Hello 丙线程
Hello 己线程
Hello 戊线程
Hello 丙线程
Hello 己线程
Hello 戊线程
Hello 丁线程
丙线程结束
戊线程结束
己线程结束
Hello 丁线程
Hello 丁线程
Hello 丁线程
Hello 丁线程
丁线程结束
  • 丙线程和丁线程锁的是同一个对象s3,所以它们不能并发运行print方法;
  • 戊线程作用的是另一个对象s4,因此它可以跟丙线程并发运行;
  • 己线程虽然也是调用对象s3,但它锁住的对象是obj,跟丙、丁锁的对象不一致,所以它也可以跟戊、丙线程并发运行。

  4.2同步普通方法

       使用Synchronize修饰普通方法时,其作用域是整个方法,锁住的对象是当前对象:

class Test1{
 
    public synchronized void test() {
 
    }
 
}
 
//等价于
class Test1{
 
    public void test() {
        //锁的是当前对象
        synchronized(this) {
 
        }
    }
 
}

       在SynchronizedUsage类中添加2个同步方法和一个普通方法:

    public synchronized void synchronizedMethod1() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "同步方法1");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void synchronizedMethod2() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "同步方法2");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void unsafeMethod() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "普通方法");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

       测试类ThreadTest添加一个测试方法testSynchronizedMethod,测试同时访问多个同步方法,及同时访问同步方法和非同步方法。

    public void testSynchronizedMethod() {
        SynchronizedUsage s5 = new SynchronizedUsage();
        SynchronizedUsage s6 = new SynchronizedUsage();
        Thread thread7 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s5.synchronizedMethod1();
            s5.synchronizedMethod2();
            s5.unsafeMethod();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread7.setName("庚线程");
        thread7.start();

        Thread thread8 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s5.unsafeMethod();
            s5.synchronizedMethod1();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread8.setName("辛线程");
        thread8.start();

        Thread thread9 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            s6.synchronizedMethod1();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread9.setName("壬线程");
        thread9.start();
    }

        测试结果如下:

庚线程启动
庚线程同步方法1
辛线程启动
壬线程启动
壬线程同步方法1
壬线程同步方法1
庚线程同步方法1
庚线程同步方法1
壬线程同步方法1
辛线程普通方法
壬线程结束
辛线程普通方法
辛线程普通方法
庚线程同步方法2
庚线程同步方法2
庚线程同步方法2
庚线程普通方法
庚线程普通方法
庚线程普通方法
辛线程同步方法1
庚线程结束
辛线程同步方法1
辛线程同步方法1
辛线程结束

        从测试结果可以得到以下结论:

  1. 一个线程访问同一个对象的两个不同的同步方法,因为是同一个对象,synchronize方法加锁指向的this也是指向同一个(当前对象),所以会导致程序串行的执行(庚线程方法1、方法2串行执行);
  2. 两个线程访问同一个对象的同步方法,争抢同一把锁,只有一个线程能拿到锁去执行,所以辛线程只能等到庚线程执行完方法1把锁释放之后,才能执行方法1;
  3. 两个线程访问两个对象的同步方法,synchronize锁的是不同的对象实例,所以两个线程不会产生互斥,并行的执行代码;
  4. 同时访问同步方法和非同步方法,非同步方法不会受到影响。

4.3同步静态方法

     作用域是整个方法,锁住的是当前类及该类的所有对象,其效果等效于在同步代码块中锁住类对象:

class Test2{
 
    public synchronized static void test() {
 
    }
 
}
 
//等价于
class Test2{
 
    public static void test() {
        //锁的是类对象,类对象只有一个
        synchronized(Test2.class) {
 
        }
    }
 
}

       在SynchronizedUsage中增加一个同步的静态方法:

    /**
     * 同步静态方法
     */
    public static synchronized void synchronizedStaticmethod() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "同步静态方法1");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

        测试类中增加对同步静态方法的测试:

    public void testSynchronizedStaticMethod() {
        Thread thread10 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            SynchronizedUsage.synchronizedStaticmethod();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread10.setName("癸线程");
        thread10.start();

        Thread thread11 = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            SynchronizedUsage.synchronizedStaticmethod();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        thread11.setName("子线程");
        thread11.start();
    }

        测试结果如下:

癸线程启动
子线程启动
癸线程同步静态方法1
癸线程同步静态方法1
癸线程同步静态方法1
子线程同步静态方法1
癸线程结束
子线程同步静态方法1
子线程同步静态方法1
子线程结束

5.synchronized的不可中断性与可重入性

5.1 不可中断性

       当锁被其他线程获得后,如果当前线程还想获得锁,那么只能进行阻塞等待,直到已获得该锁的线程释放锁再尝试去获得锁的所有权。

5.2 可重入性

        当一个线程请求另一个线程持有的锁的时候,那么请求的线程会阻塞,这是synchronized的不可中断性;但是,当线程去获取自己所拥有的锁,那么会请求成功而不会阻塞,这就是锁的可重入性。

        重入的原理是:为每个锁关联一个计数器和持有者线程,当计数器为0时候,这个锁被认为是没有被任何线程持有;当有线程持有锁,计数器自增,并且记下锁的持有线程,当同一线程继续获取锁时候,计数器继续自增;当线程退出代码块时候,相应地计数器减1,直到计数器为0,锁被释放;此时这个锁才可以被其他线程获得。

        简而言之,同一线程的外层函数获得锁之后,内层函数可以直接获取改锁,这样可以避免内外层死锁。

        SynchronizedUsageChild继承SynchronizedUsage,同时重写同步方法:

public class SynchronizedUsageChild extends SynchronizedUsage {

    @Override
    public synchronized void synchronizedMethod1() {
        System.out.println("子类在同步方法中调用父类的同步方法,验证可重入锁");
        super.synchronizedMethod1();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

        测试类增加一个验证可重入性的方法:

    public void testReentrantLock() {
        SynchronizedUsageChild child = new SynchronizedUsageChild();
        Thread t = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "启动");
            child.synchronizedMethod1();
            System.out.println(Thread.currentThread().getName() + "结束");
        });
        t.start();
    }

        测试结果如下:

Thread-0启动
子类在同步方法中调用父类的同步方法,验证可重入锁
Thread-0同步方法1
Thread-0同步方法1
Thread-0同步方法1
Thread-0结束

        分析:当线程执行SynchronizedUsageChild实例中的synchronizedMethod1方法时获得SynchronizedUsageChild实例的锁(锁的持有者是线程,锁的对象是当前实例);SynchronizedUsageChild实例在synchronizedMethod1方法中调用super.synchronizedMethod1(),调用者依然是SynchronizedUsageChild实例,再次获得的锁依然是SynchronizedUsageChild实例的锁。也就是说,两次调用synchronizedMethod1方法获得的锁都是子类SynchronizedUsageChild的实例, 如果没有重入机制,那么SynchronizedUsageChild对象在执行synchronizedMethod1方法时,会发生死锁,因为super.synchronizedMethod1()拿不到自己持有的锁,因为此时锁已经被占有,会导致线程不断等待,等待一个永远无法获得的锁。

6.使用synchronized的注意事项

6.1注意synchronized同步块的粒度

        由于锁的不可中断性,如果在同步块中包含耗时任务,就会发生严重的堵塞。此时就要减小锁的粒度,尽量从synchronized块中分离耗时且不影响共享状态的操作,去优化代码执行时间,这样即使在耗时操作的执行过程中,也不会组织其他线程访问共享状态。

        同时,由于请求和释放锁的操作需要性能开销,所以synchronized块分解得过于琐碎。

    public void doLongTimeTask() {
        try {

            System.out.println("当前线程开始:" + Thread.currentThread().getName() + ", 正在执行一个较长时间的业务操作,其内容不需要同步");
            Thread.sleep(2000);

            synchronized (this) {
                System.out.println("当前线程:" + Thread.currentThread().getName() + ", 执行同步代码块,对其同步变量进行操作");
                ++hit;
                Thread.sleep(1000);
            }
            System.out.println("当前线程结束:" + Thread.currentThread().getName() + ", 执行完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

 6.2 对 String 加锁

       (1)字符串赋值

    public void lockString() {
        //随机字符串+数字
        String s = "asdfasdf";
        synchronized (s) {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(new Date() + " " + s);
        }
    }

        测试代码:

    public void testSynchronizedString() {
        SynchronizedUsage s = new SynchronizedUsage();
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        Random random = new Random();
        for (int i = 0; i < 6; i++) {
            executorService.submit(() -> {
                s.lockString();
            });

        }
        executorService.shutdown();
    }

         测试结果如下:

Tue May 24 09:45:06 CST 2022 asdfasdf
Tue May 24 09:45:08 CST 2022 asdfasdf
Tue May 24 09:45:10 CST 2022 asdfasdf
Tue May 24 09:45:12 CST 2022 asdfasdf
Tue May 24 09:45:14 CST 2022 asdfasdf
Tue May 24 09:45:16 CST 2022 asdfasdf

        String 直接赋值是存储在JVM的常量池中的。创建过程是: 常量池中已经存在了“abc”,那么不会再创建对象,直接将引用赋值给a;如果常量池中没有“abc”,那么创建一个对象,并将引用赋值给a。当字符串相同时直接在常量池中取,加锁字符串相同可能会导致加锁对象为同一个。进一步说,先锁住已赋值的字符串,即使在同步块里该字符串字面值改变,但对象不变,所以锁的还是同一个对象。

        虽然每个线程都进行了字符串的赋值,但由于字符串的字面值相同,所以都是同一个字符串对象,所以锁住了。

       (2)new String()创建字符串

         把上例中的字符串赋值改为new String的方式:

    public void lockString() {
        String s = new String("asdfasdf");
        synchronized (s) {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(new Date() + " " + s);
        }
    }

        结果如下:

Tue May 24 10:44:14 CST 2022 asdfasdf
Tue May 24 10:44:14 CST 2022 asdfasdf
Tue May 24 10:44:14 CST 2022 asdfasdf
Tue May 24 10:44:14 CST 2022 asdfasdf
Tue May 24 10:44:14 CST 2022 asdfasdf
Tue May 24 10:44:14 CST 2022 asdfasdf

       因为new String出来的字符串,即使字符串字面值一样,都也是不同的对象,所以这里的锁不会生效。

        (3)StringBuilder

        StringBuilder.toString()方法转换成字符串时,是使用new String的方式创建字符串对象的,所以它作为锁对象的效果跟(2)一样。

      (4)字符串相加

        运行期拼接字符串其实都是StringBuilder,“+”则重复创建丢弃Stringbuider,结果也是Stringbuider.toString转换而来,所以跟(3)类似。

    public void lockString(int a) {
        //随机字符串+数字
        String s = "asdfasdf";
        String lock = s + a;
        // 输出对象物理内存地址产生的hash值
        System.out.println(lock + "的identityHashCode:" + System.identityHashCode(lock));
        System.out.println(lock + "的HashCode:" + lock.hashCode());
        synchronized (lock) {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(new Date() + " " + lock);
        }
    }

         测试代码:

    public void testSynchronizedString() {
        SynchronizedUsage s = new SynchronizedUsage();
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        Random random = new Random();
        //10个线程
        for (int i = 0; i < 6; i++) {
            executorService.submit(() -> {
                //使用随机数模拟user并发
                s.lockString(random.nextInt(3));
            });

        }
        executorService.shutdown();
    }

        结果如下:

asdfasdf1的identityHashCode:2010620583
asdfasdf0的identityHashCode:1601638310
asdfasdf0的HashCode:899636936
asdfasdf2的identityHashCode:1681172249
asdfasdf2的HashCode:899636938
asdfasdf1的HashCode:899636937
asdfasdf2的identityHashCode:1064078964
asdfasdf2的HashCode:899636938
asdfasdf1的identityHashCode:220334857
asdfasdf1的HashCode:899636937
asdfasdf0的identityHashCode:1175242183
asdfasdf0的HashCode:899636936
Mon May 23 16:19:45 CST 2022 asdfasdf0
Mon May 23 16:19:45 CST 2022 asdfasdf1
Mon May 23 16:19:45 CST 2022 asdfasdf0
Mon May 23 16:19:45 CST 2022 asdfasdf2
Mon May 23 16:19:45 CST 2022 asdfasdf1
Mon May 23 16:19:45 CST 2022 asdfasdf2

        从结果上可以看出,虽然string的字面值一样,但锁没生效。说明锁的根本就不是一个对象!字符串通过+进行拼接时,是通过Stringbuider.toString来实现的,本质上也是new String创建的字符串对象。两个字面值相同的字符串对象,其hashCode相等,因为String类重写了hashCode方法,它根据String的值来确定hashCode的值,所以只要值一样,hashCode就会相等,但其物理内存地址必不相等,所以identityHashCode也不相等。Synchronized锁的是对象,也就是identityHashCode所指向的内存地址中的对象实例(根据对象内存地址生成散列值),而hashcode输出的是值得散列值。因此,当直接锁住字符串时,锁并不会生效,因为锁的根本就不是同一个字符串对象。

        这里可以使用String的intern方法,它是一个 native 的方法,如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后再返回,因此String的intern每次返回同一个对象。

    public void lockString(int a) {
        //随机字符串+数字
        String s = "asdfasdf";
        String lock = s + a;
        synchronized (lock.intern()) {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(new Date() + " " + lock);
        }
    }

 6.3 对Integer、Long、Short等包装类加锁

public class SynchronizedInteger implements Runnable {

    static volatile Integer i = 0;

    public static void increase() {
        synchronized(i){
            ++i;
        }
    }

    @Override
    public void run() {
        for(int j = 0; j < 100000; ++j) {
            increase();
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new SynchronizedInteger());
        Thread t2 = new Thread(new SynchronizedInteger());
        t1.start();
        t2.start();
        try{
            t1.join();
            t2.join();
        } catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println(i);
    }

}

        上述代码执行的结果不是200000,执行多次结果都小于200000,对Integer的锁没作用。这是因为integer对象++操作在新增时,先拆箱然后自增,会新建一个对象来重新装箱。其他包装类如Long、Short也会有同样的问题。

         解决办法就是可以自定义一个包装类来保存Integer:

public class SynchronizedInteger2 implements Runnable {

    static WrapperInteger wrapperInteger = new WrapperInteger();

    public static void increase() {
        synchronized(wrapperInteger){
            ++wrapperInteger.i;
        }
    }

    @Override
    public void run() {
        for(int j = 0; j < 1000000; ++j) {
            increase();
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new SynchronizedInteger2());
        Thread t2 = new Thread(new SynchronizedInteger2());
        t1.start();
        t2.start();
        try{
            t1.join();
            t2.join();
        } catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println(wrapperInteger.i);
    }
}

class WrapperInteger{

    public Integer i = 0;

}

参考文章:

深入分析Synchronized原理(阿里面试题)

版权声明:程序员胖胖胖虎阿 发表于 2022年10月8日 上午4:48。
转载请注明:synchronized用法详解 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...