• 多线程系列(三) -synchronized 关键字使用详解


    一、简介

    在之前的线程系列文章中,我们介绍了线程创建的几种方式以及常用的方法介绍。

    今天我们接着聊聊多线程线程安全的问题,以及解决办法。

    实际上,在多线程环境中,难免会出现多个线程对一个对象的实例变量进行同时访问和操作,如果编程处理不当,会产生脏读现象。

    二、线程安全问题介绍

    我们先来看一个简单的线程安全问题的例子!

    public class DataEntity {
    
        private int count = 0;
    
        public void addCount(){
            count++;
        }
    
        public int getCount(){
            return count;
        }
    }
    
    public class MyThread extends Thread {
    
        private DataEntity entity;
    
        public MyThread(DataEntity entity) {
            this.entity = entity;
        }
    
        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                entity.addCount();
            }
        }
    }
    
    public class MyThreadTest {
    
        public static void main(String[] args) {
            // 初始化数据实体
            DataEntity entity = new DataEntity();
            //使用多线程编程对数据进行计算
            for (int i = 0; i < 10; i++) {
                MyThread thread = new MyThread(entity);
                thread.start();
            }
    
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("result: " + entity.getCount());
        }
    }
    

    多次运行结果如下:

    第一次运行:result: 9788554
    第二次运行:result: 9861461
    第三次运行:result: 6412249
    ...
    

    上面的代码中,总共开启了 10 个线程,每个线程都累加了 1000000 次,如果结果正确的话,自然而然总数就应该是 10 * 1000000 = 10000000。

    但是多次运行结果都不是这个数,而且每次运行结果都不一样,为什么会出现这个结果呢

    简单的说,这是主内存和线程的工作内存数据不一致,以及多线程执行时无序,共同造成的结果

    我们先简单的了解一下 Java 的内存模型,后期我们在介绍里面的原理!

    如上图所示,线程 A 和线程 B 之间,如果要完成数据通信的话,需要经历以下几个步骤:

    • 1.线程 A 从主内存中将共享变量读入线程 A 的工作内存后并进行操作,之后将数据重新写回到主内存中;
    • 2.线程 B 从主存中读取最新的共享变量,然后存入自己的工作内存中,再进行操作,数据操作完之后再重新写入到主内存中;

    如果线程 A 更新后数据并没有及时写回到主存,而此时线程 B 从主内存中读到的数据,可能就是过期的数据,于是就会出现“脏读”现象。

    因此在多线程环境下,如果不进行一定干预处理,可能就会出现像上文介绍的那样,采用多线程编程时,程序的实际运行结果与预期会不一致,就会产生非常严重的问题。

    针对多线程编程中,程序运行不安全的问题,Java 提供了synchronized关键字来解决这个问题,当多个线程同时访问共享资源时,会保证线程依次排队操作共享变量,从而保证程序的实际运行结果与预期一致。

    我们对上面示例中的DataEntity.addCount()方法进行改造,再看看效果如下。

    public class DataEntity {
    
        private int count = 0;
    
        /**
         * 在方法上加上 synchronized 关键字
         */
        public synchronized void addCount(){
            count++;
        }
    
        public int getCount(){
            return count;
        }
    }
    

    多次运行结果如下:

    第一次运行:result: 10000000
    第二次运行:result: 10000000
    第三次运行:result: 10000000
    ...
    

    运行结果与预期一致!

    三、synchronized 使用详解

    synchronized作为 Java 中的关键字,在多线程编程中,有着非常重要的地位,也是新手了解并发编程的基础,从功能角度看,它有以下几个比较重要的特性:

    • 原子性:即一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源
    • 可见性:即一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,线程获取锁之后,一定从主内存中读取数据,释放锁之前,一定会将数据写回主内存,从而保证内存数据可见性
    • 有序性:即保证程序的执行顺序会按照代码的先后顺序执行。synchronized关键字,可以保证每个线程依次排队操作共享变量

    synchronized也被称为同步锁,它可以把任意一个非 NULL 的对象当成锁,只有拿到锁的线程能进入方法体,并且只有一个线程能进入,其他的线程必须等待锁释放了才能进入,它属于独占式的悲观锁,同时也属于可重入锁。

    关于锁的知识,我们后面在介绍,大家先了解一下就行。

    从实际的使用角度来看,synchronized修饰的对象有以下几种:

    • 修饰一个方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象
    • 修饰一个静态的方法:其作用的范围是整个静态方法,作用的对象是这个类的所有对象
    • 修饰一个代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象,使用上比较灵活

    下面我们一起来看看它们的具体用法。

    3.1、修饰一个方法

    synchronized修饰一个方法时,多个线程访问同一个对象,哪个线程持有该方法所属对象的锁,就拥有执行权限,否则就只能等待。

    如果多线程访问的不是同一个对象,不会起到保证线程同步的作用

    示例如下:

    public class DataEntity {
    
        private int count;
    
        /**
         * 在方法上加上 synchronized 关键字
         */
        public synchronized void addCount(){
            for (int i = 0; i < 3; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public int getCount() {
            return count;
        }
    }
    
    public class MyThreadA extends Thread {
    
        private DataEntity entity;
    
        public MyThreadA(DataEntity entity) {
            this.entity = entity;
        }
    
        @Override
        public void run() {
            entity.addCount();
        }
    }
    
    public class MyThreadB extends Thread {
    
        private DataEntity entity;
    
        public MyThreadB(DataEntity entity) {
            this.entity = entity;
        }
    
        @Override
        public void run() {
            entity.addCount();
        }
    }
    
    public class MyThreadTest {
    
        public static void main(String[] args) {
            // 初始化数据实体
            DataEntity entity = new DataEntity();
    
            MyThreadA threadA = new MyThreadA(entity);
            threadA.start();
    
            MyThreadB threadB = new MyThreadB(entity);
            threadB.start();
    
    
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("result: " + entity.getCount());
        }
    }
    

    运行结果如下:

    Thread-0:0
    Thread-0:1
    Thread-0:2
    Thread-1:3
    Thread-1:4
    Thread-1:5
    result: 6
    

    当两个线程共同操作一个对象时,此时每个线程都会依次排队执行。

    假如两个线程操作的不是一个对象,此时没有任何效果,示例如下:

    public class MyThreadTest {
    
        public static void main(String[] args) {
            DataEntity entity1 = new DataEntity();
            MyThreadA threadA = new MyThreadA(entity1);
            threadA.start();
    
            DataEntity entity2 = new DataEntity();
            MyThreadA threadB = new MyThreadA(entity2);
            threadB.start();
    
    
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("result: " + entity1.getCount());
            System.out.println("result: " + entity2.getCount());
        }
    }
    

    运行结果如下:

    Thread-0:0
    Thread-1:0
    Thread-0:1
    Thread-1:1
    Thread-0:2
    Thread-1:2
    result: 3
    result: 3
    

    从结果上可以看出,当synchronized修饰一个方法,当多个线程访问同一个对象的方法,每个线程会依次排队;如果访问的不是一个对象,线程不会进行排队,像正常执行一样。

    3.2、修饰一个静态的方法

    synchronized修改一个静态的方法时,代表的是对当前.java文件对应的 Class 类加锁,不区分对象实例。

    示例如下:

    public class DataEntity {
    
        private static int count;
    
        /**
         * 在静态方法上加上 synchronized 关键字
         */
        public synchronized static void addCount(){
            for (int i = 0; i < 3; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public static int getCount() {
            return count;
        }
    }
    
    public class MyThreadA extends Thread {
    
        @Override
        public void run() {
            DataEntity.addCount();
        }
    }
    
    public class MyThreadB extends Thread {
    
        @Override
        public void run() {
            DataEntity.addCount();
        }
    }
    
    public class MyThreadTest {
    
        public static void main(String[] args) {
    
            MyThreadA threadA = new MyThreadA();
            threadA.start();
    
            MyThreadB threadB = new MyThreadB();
            threadB.start();
    
    
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("result: " + DataEntity.getCount());
        }
    }
    

    运行结果如下:

    Thread-0:0
    Thread-0:1
    Thread-0:2
    Thread-1:3
    Thread-1:4
    Thread-1:5
    result: 6
    

    静态同步方法和非静态同步方法持有的是不同的锁,前者是类锁,后者是对象锁,类锁可以理解为这个类的所有对象。

    3.3、修饰一个代码块

    synchronized用于修饰一个代码块时,只会控制代码块内的执行顺序,其他试图访问该对象的线程将被阻塞,编程比较灵活,在实际开发中用的应用比较广泛。

    示例如下

    public class DataEntity {
    
        private int count;
    
        /**
         * 在方法上加上 synchronized 关键字
         */
        public void addCount(){
            synchronized (this){
                for (int i = 0; i < 3; i++) {
                    try {
                        System.out.println(Thread.currentThread().getName() + ":" + (count++));
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public int getCount() {
            return count;
        }
    }
    
    public class MyThreadTest {
    
        public static void main(String[] args) {
            // 初始化数据实体
            DataEntity entity = new DataEntity();
    
            MyThreadA threadA = new MyThreadA(entity);
            threadA.start();
    
            MyThreadB threadB = new MyThreadB(entity);
            threadB.start();
    
    
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("result: " + entity.getCount());
        }
    }
    

    运行结果如下:

    Thread-0:0
    Thread-0:1
    Thread-0:2
    Thread-1:3
    Thread-1:4
    Thread-1:5
    result: 6
    

    其中synchronized (this)中的this,表示的是当前类实例的对象,效果等同于public synchronized void addCount()

    除此之外,synchronized()还可以修饰任意实例对象,作用的范围就是具体的实例对象。

    比如,修饰个自定义的类实例对象,作用的范围是拥有lock对象,其实也等价于synchronized (this)

    public class DataEntity {
    
        private Object lock = new Object();
    
        /**
         * synchronized 可以修饰任意实例对象
         */
        public void addCount(){
            synchronized (lock){
                // todo...
            }
        }
    }
    

    当然也可以用于修饰类,表示类锁,效果等同于public synchronized static void addCount()

    public class DataEntity {
        
        /**
         * synchronized 可以修饰类,表示类锁
         */
        public void addCount(){
            synchronized (DataEntity.class){
                // todo...
            }
        }
    }
    

    synchronized修饰代码块,比较经典的应用案例,就是单例设计模式中的双重校验锁实现。

    public class Singleton {  
    
        private volatile static Singleton singleton;  
        
        private Singleton (){}  
        
        public static Singleton getSingleton() {  
            if (singleton == null) {  
                synchronized (Singleton.class) {  
                    if (singleton == null) {  
                        singleton = new Singleton();  
                    }  
                }  
            }  
            return singleton;  
        }  
    }
    

    采用代码块的实现方式,编程会更加灵活,可以显著的提升并发查询的效率。

    四、synchronized 锁重入介绍

    synchronized关键字拥有锁重入的功能,所谓锁重入的意思就是:当一个线程得到一个对象锁后,再次请求此对象锁时可以再次得到该对象的锁,而无需等待。

    我们看个例子就能明白。

    public class DataEntity {
    
        private int count = 0;
    
        
        public synchronized void addCount1(){
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            addCount2();
        }
    
        public synchronized void addCount2(){
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            addCount3();
        }
    
        public synchronized void addCount3(){
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
    
        }
    
        public int getCount() {
            return count;
        }
    }
    
    public class MyThreadA extends Thread {
    
        private DataEntity entity;
    
        public MyThreadA(DataEntity entity) {
            this.entity = entity;
        }
    
        @Override
        public void run() {
            entity.addCount1();
        }
    }
    
    public class MyThreadB extends Thread {
    
        private DataEntity entity;
    
        public MyThreadB(DataEntity entity) {
            this.entity = entity;
        }
    
        @Override
        public void run() {
            entity.addCount1();
        }
    }
    
    public class MyThreadTest {
    
        public static void main(String[] args) {
            // 初始化数据实体
            DataEntity entity = new DataEntity();
    
            MyThreadA threadA = new MyThreadA(entity);
            threadA.start();
    
            MyThreadB threadB = new MyThreadB(entity);
            threadB.start();
    
    
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("result: " + entity.getCount());
        }
    }
    

    运行结果如下:

    Thread-0:0
    Thread-0:1
    Thread-0:2
    Thread-1:3
    Thread-1:4
    Thread-1:5
    result: 6
    

    从结果上看线程没有交替执行,线程Thread-0获取到锁之后,再次调用其它带有synchronized关键字的方法时,可以快速进入,而Thread-1线程需等待对象锁完全释放之后再获取,这就是锁重入。

    五、小结

    从上文中我们可以得知,在多线程环境下,恰当的使用synchronized关键字可以保证线程同步,使程序的运行结果与预期一致。

    • 1.当synchronized修饰一个方法时,作用的范围是整个方法,作用的对象是调用这个方法的对象;
    • 2..当synchronized修饰一个静态方法时,作用的范围是整个静态方法,作用的对象是这个类的所有对象;
    • 3.当synchronized修饰一个代码块时,作用的范围是代码块,作用的对象是修饰的内容,如果是类,则这个类的所有对象都会受到控制;如果是任意对象实例子,则控制的是具体的对象实例,谁拥有这个对象锁,就能进入方法体

    synchronized是一种同步锁,属于独占式,使用它进行线程同步,JVM 性能开销很大,大量的使用未必会带来好处。

    关于更深入的原理知识,我们会在 JVM 系列中进行详解。文章内容难免有所遗漏,欢迎网友留言指出。

    六、参考

    1、五月的仓颉 - synchronized锁机制

    2、你听 - 让你彻底理解Synchronized

  • 相关阅读:
    使用 Next.js 连接 mysql 数据库
    代码随想录Day61 | 503. 下一个更大元素 II | 42. 接雨水
    创新与重塑,佛塑科技打造集团型 CRM 建设标杆
    HackTheBox-Machines--Codify
    Day 61 Ajax
    Jnekins Active动态参数 集成Gitlab实践
    【MySQL】开启 canal同步MySQL增量数据到ES
    【七:docken+jenkens部署】
    JSP自定义标签(foreach标签与select标签的自定义方式)
    进来了解实现官网搜索引擎的三种方法
  • 原文地址:https://www.cnblogs.com/dxflqm/p/18022798