线程同步

作者: 官先生Y | 来源:发表于2018-04-17 08:40 被阅读19次

    理解同步

    在应用程序中,当多个线程需要对同一个资源进行读写操作时,可能会引起冲突。为了解决这个问题,就需要保证这个共享资源在某一时刻只能有唯一的一个线程对它操作。使用线程同步就可以解决以上问题。
    即解决了同一时刻,只能有一个线程访问临界资源。

    关于线程同步,需要牢牢记住的是:

    • 线程同步就是线程排队。同步就是排队。线程同步的目的就是避免线程“同步”执行。
    • 只有共享资源的读写访问才需要同步。如果不是共享资源,那么就根本没有同步的必要。

    线程安全问题产生的原因以及解决思路

    场景&代码示例

    场景:车站售卖火车票,火车票是一定的,但卖火车票的窗口到处都有,每个窗口就相当于一个线程,这么多的线程公用所有的火车票资源。

    public class TrainTicket implements Runnable {
        private int ticket = 20;
    
        @Override
        public void run() {
            while (true) {
                if (ticket == 0) {
                    break;
                }
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "=========" + ticket--);
            }
        }
    
        public static void main(String[] args) {
            TrainTicket trainTicket = new TrainTicket();
            new Thread(trainTicket, "Thread-火车票售卖窗口1").start();
            new Thread(trainTicket, "Thread-火车票售卖窗口2").start();
        }
    }
    

    输出结果:

    Thread-火车票售卖窗口1=========20
    Thread-火车票售卖窗口2=========20
    Thread-火车票售卖窗口1=========19
    Thread-火车票售卖窗口2=========18
    Thread-火车票售卖窗口1=========17
    Thread-火车票售卖窗口2=========17
    Thread-火车票售卖窗口1=========16
    Thread-火车票售卖窗口2=========16
    Thread-火车票售卖窗口2=========14
    Thread-火车票售卖窗口1=========15
    Thread-火车票售卖窗口2=========13
    Thread-火车票售卖窗口1=========12
    Thread-火车票售卖窗口2=========11
    Thread-火车票售卖窗口1=========10
    Thread-火车票售卖窗口2=========9
    Thread-火车票售卖窗口1=========8
    Thread-火车票售卖窗口2=========7
    Thread-火车票售卖窗口1=========6
    Thread-火车票售卖窗口2=========5
    Thread-火车票售卖窗口1=========4
    Thread-火车票售卖窗口1=========3
    Thread-火车票售卖窗口2=========2
    Thread-火车票售卖窗口1=========1
    Thread-火车票售卖窗口2=========0
    Thread-火车票售卖窗口1=========-1
    Thread-火车票售卖窗口2=========-2
    Thread-火车票售卖窗口1=========-3
    Thread-火车票售卖窗口2=========-3
    Thread-火车票售卖窗口2=========-4
    Thread-火车票售卖窗口1=========-5
    Thread-火车票售卖窗口2=========-6
    Thread-火车票售卖窗口1=========-7
    

    从输出结果看,这不是我们想要的结果。

    线程安全问题产生的原因

    1. 多个线程在操作同一个数据
    2. 线程代码中有多条操作共享数据的语句
      (1和2可理解为共享资源)
    3. 当一个线程在执行操作共享数据的多条代码过程中,其他线程参与了运算。就会导致线程安全问题的产生。

    解决思路

    将共享资源封装起来,当有线程在执行这些代码的时候,其他线程时不可以参与运算的。必须要当前线程把这些代码都执行完毕后,其他线程才可以参与运算。在java中,如何实现以上思路请看下面。

    实现同步

    思路:
    1.加锁机制
    访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。
    2.免锁机制
    通过volatile关键字为实例域的同步访问提供了免锁机制。(有前提,较局限)

    synchronized

    概述

    • synchronized是Java语言的关键字,是Java实现线程同步的一种方式。

    原理

    • 在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器。可以使用synchronized关键字来标记一个方法或者代码块,当某个线程调用该对象的synchronized方法或者访问synchronized代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法或者代码块。

    使用

    • 在方法上添加synchronized关键字
    • 同步代码块,即synchronized(对象) { }

    作用对象

    synchronized方法
    非静态方法 调用这个方法的对象
    静态方法 这个类的所有对象

    synchronized代码块
    synchronized(),()中是锁住的对象
    例如:

    1. synchronized(this)锁住的只是对象本身,同一个类的不同对象调用的synchronized方法并不会被锁住,
    2. synchronized(className.class)实现了全局锁的功能,所有这个类的对象调用这个方法都受到锁的影响,
    3. ()中还可以添加一个具体的对象,实现给具体对象加锁。

    其它

    • 使用synchronized修饰的代码具有原子性、可见性和有序性。

    注意事项

    注意1

    当两个并发线程访问同一个对象中的synchronized代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。两个线程间是互斥的,因为在执行synchronized代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。

    public class SynchronizedDemo01 implements Runnable {
        public void run() {
            synchronized (this) {
                for (int i = 0; i < 5; i++) {
                    System.out.println(Thread.currentThread().getName() + " synchronized loop " + i);
                }
            }
            System.out.println(Thread.currentThread().getName() + "在synchronized loop 之后");
        }
    
        public static void main(String[] args) {
            SynchronizedDemo01 t1 = new SynchronizedDemo01();
            Thread ta = new Thread(t1, "A");
            Thread tb = new Thread(t1, "B");
            ta.start();
            tb.start();
        }
    }
    

    输出结果

    A synchronized loop 0
    A synchronized loop 1
    A synchronized loop 2
    A synchronized loop 3
    A synchronized loop 4
    A在synchronized loop 之后
    B synchronized loop 0
    B synchronized loop 1
    B synchronized loop 2
    B synchronized loop 3
    B synchronized loop 4
    B在synchronized loop 之后
    
    注意2

    当一个线程访问某个对象的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该对象中的非synchronized(this)同步代码块。

    public class SynchronizedDemo02 {
        public void m4t1() {
            synchronized (this) {
                int i = 5;
                while (i-- > 0) {
                    System.out.println(Thread.currentThread().getName() + " : " + i);
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException ie) {
                    }
                }
            }
        }
    
        public void m4t2() {
            int i = 5;
            while (i-- > 0) {
                System.out.println(Thread.currentThread().getName() + " : " + i);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ie) {
                }
            }
        }
    
        public static void main(String[] args) {
            final SynchronizedDemo02 myt2 = new SynchronizedDemo02();
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    myt2.m4t1();
                }
            }, "t1");
            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    myt2.m4t2();
                }
            }, "t2");
            t1.start();
            t2.start();
        }
    }
    
    

    输出结果

    t2 : 4
    t1 : 4
    t2 : 3
    t1 : 3
    t2 : 2
    t1 : 2
    t1 : 1
    t2 : 1
    t1 : 0
    t2 : 0
    
    注意3

    当一个线程访问某个对象的一个synchronized(this)同步代码块时,其他线程对此对象中所有其它synchronized(this)同步代码块的访问将被阻塞

    //修改SynchronizedDemo02.m4t2()方法:  
         public void m4t2() {  
              synchronized(this) {  
                   int i = 5;  
                   while( i-- > 0) {  
                        System.out.println(Thread.currentThread().getName() + " : " + i);  
                        try {  
                             Thread.sleep(500);  
                        } catch (InterruptedException ie) {  
                        }  
                   }  
              }
    
         }
    
    t1 : 4  
    t1 : 3  
    t1 : 2  
    t1 : 1  
    t1 : 0  
    t2 : 4  
    t2 : 3  
    t2 : 2  
    t2 : 1  
    t2 : 0
    
    注意4

    每个类也会有一个锁,它可以用来控制对static数据成员的并发访问。
    并且如果一个线程执行一个对象的非static synchronized方法,另外一个线程需要执行这个对象所属类的static synchronized方法,此时不会发生互斥现象,因为访问static synchronized方法占用的是类锁,而访问非static synchronized方法占用的是对象锁,所以不存在互斥现象。
    代码如下:

    public class SynchronizedDemo03 {
        public static void main(String[] args) {
            final InsertData insertData = new InsertData();
            new Thread() {
                @Override
                public void run() {
                    insertData.insert();
                }
            }.start();
            new Thread() {
                @Override
                public void run() {
                    insertData.insert1();
                }
            }.start();
        }
    }
    
    class InsertData {
        public synchronized void insert() {
            System.out.println("执行insert");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("执行insert完毕");
        }
    
        public synchronized static void insert1() {
            System.out.println("执行insert1");
            System.out.println("执行insert1完毕");
        }
    }
    
    执行insert
    执行insert1
    执行insert1完毕
    执行insert完毕
    

    第一个线程里面执行的是insert方法,不会导致第二个线程执行insert1方法发生阻塞现象。

    重入锁与条件对象

    概述

    重入锁ReentrantLock是JavaSE5.0引入的,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。

    用法

    用ReentrantLock保护代码块的结构如下所示:

    Lock lock = new ReentrantLock();
    lock.lock();
           
    try {
         ...
    }finally {
         lock.unlock();
    }
    

    这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其它任何线程都无法进入Lock语句。把解锁的操作放在finally中是十分必要的。如果在临界区发生了异常,锁是必须释放的,否则其它线程将会永远被阻塞。进入临界区时,却发现在某一个满足条件之后,它才能执行。这时可以使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用 工作的线程,条件对象又被称作条件变量。

    支付宝场景代码示例

    例子:支付宝转账时,结果我们发现转账方余额不足;如果有其他线程给这个转账方再转足够的钱,就可以转账成功了。但是这个线程已经获取了锁,它具有排他性,别的线程无法获取锁来进行存款操作,这就是我们需要引入条件对象的原因。一个锁对象拥有多个相关的条件对象,可以用newCondition方法获得一个条件对象,我们得到条件对象后调用await方法,当前线程就被阻塞了并放弃了锁。
    一旦一个线程调用await方法,它就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同一个条件的signalAll方法时为止。当另一个线程转账给我们此前的转账方时,只要调用condition.signalAll(),就会重新激活因为这一条件而等待的所有线程。
    当调用signalAll方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能够在当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法时signal,它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞。如果没有其他线程再次调用signal,那么系统就死锁了。

    例子代码如下所示:

    public class Alipay {
    
        private double[] mAccounts;
        private Lock mLock;
        private Condition mCondition;
    
        /**
         * @param n     支付宝账户的数量
         * @param money
         */
        public Alipay(int n, int money) {
    
            mAccounts = new double[n];
    
            mLock = new ReentrantLock();
            mCondition = mLock.newCondition();
            for (int i = 0; i < mAccounts.length; i++) {
                mAccounts[i] = money;
            }
        }
    
        /**
         * @param from   转账方
         * @param to     接收方
         * @param amount 转账金额
         */
        public void transfer(int from, int to, int amount) throws InterruptedException {
            mLock.lock();
    
            try {
                while (mAccounts[from] < amount) {
                    //阻塞当前线程,并放弃锁
                    mCondition.await();
                }
    
                //转账的操作
                mAccounts[from] = mAccounts[from] - amount;
                mAccounts[to] = mAccounts[to] + amount;
                mCondition.signalAll();
            } finally {
                mLock.unlock();
            }
        }
    
    }
    

    ReentrantLock与synchronized的关系

    • 都是通过锁机制来实现线程同步
    • 能力圈
      ReentrantLock除了能完成synchronized所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
    • 机理
      synchronized实现的机理依赖于软件层面上的JVM,Lock实现的机理依赖于特殊的CPU指定,可以认为不受JVM的约束,并可以通过其他语言平台来完成底层的实现。
    • 性能
      在并发量较小的多线程应用程序中,ReentrantLock与synchronized性能相差无几,但在高并发量的条件下,synchronized性能会迅速下降几十倍,而ReentrantLock的性能却能依然维持一个水准,因此我们建议在高并发量情况下使用ReentrantLock。

    Lock和Synchronized的互换关系示例

    从Java1.0开始,Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁保护整个方法。也就是说,要调用该同步方法,线程必须获得内部的对象锁。

    public synchronized void method(){
            //...
    }
    

    等价于

    Lock mLock = new ReentrantLock();
    public void method(){
            mLock.lock();
            try {
                //...
            }finally {
                mLock.unlock();
            }
    
    }
    

    对于上面支付宝转账的例子,我们可以将Alipay类的transfer方法声明为synchronized,而不是使用一个显示的锁。内部对象锁只有一个相关条件,wait方法将一个线程添加到等待集中,notifyAll或者notify方法解除等待线程的阻塞状态。也就是说wait相当于调用condition.await(),notifyAll等价于condition.signalAll();
    上面例子中的transfer方法也可以这样写:

    public synchronized void transfer(int from, int to, int amount) throws InterruptedException {
            while (mAccounts[from] < amount) {
                wait();
            }
    
            //转账的操作
            mAccounts[from] = mAccounts[from] - amount;
            mAccounts[to] = mAccounts[to] + amount;
            notifyAll();
    }
    

    可以看到使用synchronized关键字来编写代码要简洁很多。当然要理解这一代码,你必须要了解每一个对象有一个内部锁,并且该锁有一个内部条件。由该锁来管理那些试图进入synchronized方法的线程,由该锁中的条件来管理那些调用wait的线程。

    volatile

    作用

    当一个共享变量被volatile修饰之后,其就具备了两个含义:

    1. 线程修改了变量的值时,变量的新值对其他线程是立即可见的。即不同线程对这个变量进行操作时具有可见性。
    2. 禁止使用指令重排序

    什么是重排序?
    重排序通常是编译器或运行时坏境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。
    重排序分为两类:编译器重排序和运行期重排序,分别对应编译时和运行时坏境。

    与线程安全3条性质关系

    • 不保证原子性
      例子:
      自增操作i++有3个子操作,用volatile修饰i,不能保证对变脸i的操作是原子性的。

    • 保证可见性
      volatile保证线程之间的可见性原理:
      强制将修改的值立即写入主存,并且会导致其它线程的工作内存中变量的缓存失效。

    • 保证有序性
      为什么?
      volatile关键字能禁止指令重排序,因此volatile能保证有序性。

    更具体原理?
    volatile关键字禁止指令重排序有两个含义:
    一个是当程序执行到volatile变量的操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还没有进行;
    在进行指令优化时,在volatile变量之前的语句不能在volatile变量之后执行;同样,在volatile变量之后的语句也不能在volatile变量前面执行。

    正确使用volatile关键字

    • 使用volatile前提条件
    1. 对变量的写操作不会依赖当前值;
    2. 该变量没有包含在具有其他变量的不变式中。

    即变量真正独立于其他变量和自己以前的值

    • 应用场景
      • 状态标志
      • 单例模式的实现模式之一双重检查模式DCL

    与锁机制比较

    volatile与锁相比,volatile变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循volatile的使用条件,即变量真正独立于其他变量和自己以前的值,在某些情况下可以使用volatile代替synchronized来简化代码。然而,使用volatile代替synchronized的代码往往比使用锁的代码更加容器出错。除了前面列出的应用场景的两种用例,在其他情况下我们最好还是使用synchronized。

    相关文章

      网友评论

        本文标题:线程同步

        本文链接:https://www.haomeiwen.com/subject/vgvmzxtx.html