美文网首页
Java 中的锁

Java 中的锁

作者: 卡戎li | 来源:发表于2017-08-22 15:00 被阅读0次

    1、什么是锁

    谈到java中的锁,在我的印象中,无非是synchronized和lock这两个模糊的概念。大致知道他们的区别:synchronized是基于jvm的锁机制,不需要自己释放锁;lock较前者更灵活,但是需要自己获取和释放锁。两者都是用于在多线程中保证线程安全。

    2、锁相关的知识

    和锁相关概念有这些:

    • 线程安全
    • 主内存和工作内存
    • 自旋锁
    • 锁消除和锁粗化
    • volatile
    • CAS
    • 互斥同步

    3、线程和进程

    3.1 进程

    进程是os中资源分配和独立运行的基本单位,包含程序、数据和进程控制块(PCB)。

    • 程序用于描述进程要完成的功能,是控制进程执行的指令集;
    • 数据集合是程序在执行时所需要的数据和工作区;
    • 程序控制块(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。
      其两个基本属性是:
    • 进程是一个可拥有资源的独立单位;
    • 进程同时又是一个可独立调度和分派的基本单位。这两个基本属性,构成了进程并发执行的基础。

    3.2 线程

    线程被称为轻量级进程,使得每个进程拥有多个线程。进程的引用是为了似多个程序并发执行,来提高资源利用率和系统吞吐量,但是由于进程创建、撤销和切换PCB的时候,会带来相对大量的时空消耗,在这种场景下,线程便应运而生。所以线程解决的问题就是既要保证多个程序并发执行,又要尽量优化和减少时空消耗。线程拥有以下属性:

    • 轻量实体,拥有极少的系统资源,来保证它能独立运行;
    • 独立调度和分派的基本单位,由于线程很轻,所以调度和切换非常迅速而且开销小;
    • 可并发执行,这个没什么可说的,咱就是为了解决这个问题的;
    • 共享进程的资源,在同一个进程中,各个线程可以共享该进程的所有资源,这就是线程和进程最核心的区别。

    线程的生命周期:

    线程的生命周期.png

    3.3 进程和线程的区别

    • 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
    • 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
    • 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
    • 调度和切换:线程上下文切换比进程上下文切换要快得多。

    线程与进程关系的示意图:


    进程和线程的示例图.png 单线程和多线程之间的关系.png

    4、如何做到线程安全

    多线程中,三个核心概念:原子性、可见性和顺序性。

    • 原子性 顾名思义就是不可分割的操作,一个操作(有可能包含有多个子操作)要么全部执行,要么全部都不执行。
    • 可见性 就是多个线程访问同一个资源,其中一个线程修改共享的变量,其他的线程立即能看到,CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。
    • 顺序性 就是程序执行的顺序是按照代码的先后顺序执行的。

    4.1 保证达到以上三个核心点就能保证线程安全。三种方案:

    • 互斥同步
      最常见的并发正确性保障手段,同步至多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。

    • 非阻塞同步
      互斥同步的主要问题就是进行线程的阻塞和唤醒所带来的性能问题,因此这个同步也被称为阻塞同步,阻塞同步属于一种悲观的并发策略,认为只要不去做正确的同步措施,就肯定会出问题,无论共享的数据是否会出现竞争。随着硬件指令的发展,有了另外一个选择,基于冲突检测的乐观并发策略,通俗的讲就是先进性操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就再进行其他的补偿措施(最常见的措施就是不断的重试,直到成功为止),这种策略不需要把线程挂起,所以这种同步也被称为非阻塞同步。

    • 无同步方案
      简单的理解就是没有共享变量需要不同的线程去争用,目前有两种方案,一个是“可重入代码”,这种代码可以在执行的任何时刻中断它,转而去执行其他的另外一段代码,当控制权返回时,程序继续执行,不会出现任何错误。一个是“线程本地存储”,如果变量要被多线程访问,可以使用volatile关键字来声明它为“易变的“,以此来实现多线程之间的可见性。同时也可以通过ThreadLocal来实现线程本地存储的功能,一个线程的Thread对象中都有一个ThreadLocalMap对象,来实现KV数据的存储。

    5、 CAS 锁

    CAS就是Compare And Swap,比较和替换的意思,是设计并发算法时用到的一种技术。比较和替换是使用一个期望值和一个变量的当前置比较,如果当前变量值等于期望值,就使用一个新值替换当前变量值。换言之CAS有三个操作数:内存值V、旧的预期值A、要修改的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做并返回false,是不是很好理解。
    CAS其实就是用作原子操作的,java.util.concurrent包是完全建立在CAS之上的。当前的处理器基本都支持CAS。

    6、乐观锁和悲观锁

    6.1 悲观锁(Pessimistic Lock)

    具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他[事务],以及来自外部系统的[事务处理]修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的[排他性],否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

    6.2 乐观锁( Optimistic Locking )

    相对[悲观锁]而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长[事务]而言,这样的开销往往无法承受。而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

    简而言之:
    悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
    乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。乐观锁不能解决脏读的问题。

    7、Java中的乐观锁和悲观锁

    我们都知道,cpu是时分复用的,也就是把cpu的时间片,分配给不同的thread/process轮流执行,时间片与时间片之间,需要进行cpu切换,也就是会发生进程的切换。切换涉及到清空寄存器,缓存数据。然后重新加载新的thread所需数据。当一个线程被挂起时,加入到阻塞队列,在一定的时间或条件下,在通过notify(),notifyAll()唤醒回来。在某个资源不可用的时候,就将cpu让出,把当前等待线程切换为阻塞状态。等到资源(比如一个共享数据)可用了,那么就将线程唤醒,让他进入runnable状态等待cpu调度。这就是典型的悲观锁的实现。独占锁是一种悲观锁,synchronized就是一种独占锁,它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。
    但是,由于在进程挂起和恢复执行过程中存在着很大的开销。当一个线程正在等待锁时,它不能做任何事,所以悲观锁有很大的缺点。举个例子,如果一个线程需要某个资源,但是这个资源的占用时间很短,当线程第一次抢占这个资源时,可能这个资源被占用,如果此时挂起这个线程,可能立刻就发现资源可用,然后又需要花费很长的时间重新抢占锁,时间代价就会非常的高。
    所以就有了乐观锁的概念,他的核心思路就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。在上面的例子中,某个线程可以不让出cpu,而是一直while循环,如果失败就重试,直到成功为止。所以,当数据争用不严重时,乐观锁效果更好。比如CAS就是一种乐观锁思想的应用。
    JDK1.5中引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS的操作,并且JVM把它们编译为底层硬件提供的最有效的方法,在运行CAS的平台上,运行时把它们编译为相应的机器指令。在java.util.concurrent.atomic包下面的所有的原子变量类型中,比如AtomicInteger,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作。
    在CAS操作中,会出现ABA问题。就是如果V的值先由A变成B,再由B变成A,那么仍然认为是发生了变化,并需要重新执行算法中的步骤。有简单的解决方案:不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号,即使这个值由A变为B,然后为变为A,版本号也是不同的。AtomicStampedReference和AtomicMarkableReference支持在两个变量上执行原子的条件更新。AtomicStampedReference更新一个“对象-引用”二元组,通过在引用上加上“版本号”,从而避免ABA问题,AtomicMarkableReference将更新一个“对象引用-布尔值”的二元组。

    8、 synchronized

    synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:

    • 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
    • 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
    • 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
    • 修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。

    8.1 修饰方法中的代码块

    package com.threadTest;
    
    /**
     * Created by lipei on 2017/8/22.
     */
    public class SyncThreadTest {
    
        public static void main(String[] args) {
            SyncThread syncThread = new SyncThread();
            Thread thread1 = new Thread(syncThread, "SyncThread1");
            Thread thread2 = new Thread(syncThread, "SyncThread2");
            thread1.start();
            thread2.start();
        }
    }
    
    
    class SyncThread implements Runnable {
        private static int count;
    
        public SyncThread() {
            count = 0;
        }
    
        public  void run() {
            synchronized(this) {
                for (int i = 0; i < 5; i++) {
                    try {
                        System.out.println(Thread.currentThread().getName() + ":" + (count++));
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public int getCount() {
            return count;
        }
    }
    

    运行结果:

    SyncThread1:0
    SyncThread1:1
    SyncThread1:2
    SyncThread1:3
    SyncThread1:4
    SyncThread2:5
    SyncThread2:6
    SyncThread2:7
    SyncThread2:8
    SyncThread2:9
    

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

    package com.threadTest;
    
    /**
     * Created by lipei on 2017/8/22.
     */
    public class SyncThreadTest {
    
        public static void main(String[] args) {
            Thread thread1 = new Thread(new SyncThread(), "SyncThread1");
            Thread thread2 = new Thread(new SyncThread(), "SyncThread2");
            thread1.start();
            thread2.start();
        }
    }
    
    
    class SyncThread implements Runnable {
        private static int count;
    
        public SyncThread() {
            count = 0;
        }
    
        public  void run() {
            synchronized(this) {
                for (int i = 0; i < 5; i++) {
                    try {
                        System.out.println(Thread.currentThread().getName() + ":" + (count++));
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public int getCount() {
            return count;
        }
    }
    

    运行结果

    SyncThread1:0
    SyncThread2:1
    SyncThread2:2
    SyncThread1:2
    SyncThread1:3
    SyncThread2:3
    SyncThread1:4
    SyncThread2:5
    SyncThread2:7
    SyncThread1:6
    

    这时创建了两个SyncThread的对象syncThread1和syncThread2,线程thread1执行的是syncThread1对象中的synchronized代码(run),而线程thread2执行的是syncThread2对象中的synchronized代码(run);我们知道synchronized锁定的是对象,这时会有两把锁分别锁定syncThread1对象和syncThread2对象,而这两把锁是互不干扰的,不形成互斥,所以两个线程可以同时执行。

    8.2 同一类中既有同步块也有非同步块

    package com.threadTest;
    
    /**
     * Created by lipei on 2017/8/22.
     */
    public class SyncThreadTest {
    
        public static void main(String[] args) {
            Counter counter = new Counter();
            Thread thread1 = new Thread(counter, "A");
            Thread thread2 = new Thread(counter, "B");
            thread1.start();
            thread2.start();
        }
    }
    
    
    class Counter implements Runnable {
        private int count;
    
        public Counter() {
            count = 0;
        }
    
        public void countAdd() {
            synchronized (this) {
                for (int i = 0; i < 5; i++) {
                    try {
                        System.out.println(Thread.currentThread().getName() + ":" + (count++));
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        //非synchronized代码块,未对count进行读写操作,所以可以不用synchronized
        public void printCount() {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + " count:" + count);
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    
        public void run() {
            String threadName = Thread.currentThread().getName();
            if (threadName.equals("A")) {
                countAdd();
            } else if (threadName.equals("B")) {
                printCount();
            }
        }
    }
    

    运行结果

    A:0
    B count:1
    A:1
    B count:2
    A:2
    B count:3
    A:3
    B count:4
    A:4
    B count:5
    

    上面代码中countAdd是一个synchronized的,printCount是非synchronized的。从上面的结果中可以看出一个线程访问一个对象的synchronized代码块时,别的线程可以访问该对象的非synchronized代码块而不受阻塞。

    8.3 给对象加锁

    package com.threadTest;
    
    /**
     * Created by lipei on 2017/8/22.
     */
    public class SyncThreadTest {
    
        public static void main(String[] args) {
            Account account = new Account("zhang san", 10000.0f);
            AccountOperator accountOperator = new AccountOperator(account);
    
            final int THREAD_NUM = 5;
            Thread threads[] = new Thread[THREAD_NUM];
            for (int i = 0; i < THREAD_NUM; i ++) {
                threads[i] = new Thread(accountOperator, "Thread" + i);
                threads[i].start();
            }
        }
    }
    
    
    /**
     * 银行账户类
     */
    class Account {
        String name;
        float amount;
    
        public Account(String name, float amount) {
            this.name = name;
            this.amount = amount;
        }
        //存钱
        public  void deposit(float amt) {
            amount += amt;
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //取钱
        public  void withdraw(float amt) {
            amount -= amt;
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        public float getBalance() {
            return amount;
        }
    }
    
    /**
     * 账户操作类
     */
    class AccountOperator implements Runnable{
        private Account account;
        public AccountOperator(Account account) {
            this.account = account;
        }
    
        public void run() {
            synchronized (account) {
                account.deposit(500);
                account.withdraw(500);
                System.out.println(Thread.currentThread().getName() + ":" + account.getBalance());
            }
        }
    }
    

    运行结果

    Thread0:10000.0
    Thread4:10000.0
    Thread3:10000.0
    Thread2:10000.0
    Thread1:10000.0
    

    在AccountOperator 类中的run方法里,我们用synchronized 给account对象加了锁。这时,当一个线程访问account对象时,其他试图访问account对象的线程将会阻塞,直到该线程访问account对象结束。也就是说谁拿到那个锁谁就可以运行它所控制的那段代码。
    当有一个明确的对象作为锁时,就可以用类似下面这样的方式写程序。

    public void method3(SomeObject obj)
    {
       //obj 锁定的对象
       synchronized(obj)
       {
          // todo
       }
    }
    

    当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁:

    class Test implements Runnable
    {
       private byte[] lock = new byte[0];  // 特殊的instance变量
       public void method()
       {
          synchronized(lock) {
             // todo 同步代码块
          }
       }
    
       public void run() {
    
       }
    }
    

    说明:零长度的byte数组对象创建起来将比任何对象都经济――查看编译后的字节码:生成零长度的byte[]对象只需3条操作码,而Object lock = new Object()则需要7行操作码。

    8.4 给静态方法加锁

    修饰一个静态的方法

    Synchronized也可修饰一个静态方法,用法如下:

    public synchronized static void method() {
       // todo
    }
    

    我们知道静态方法是属于类的而不属于对象的。同样的,synchronized修饰的静态方法锁定的是这个类的所有对象。

    8.5 给类加锁

    Synchronized还可作用于一个类,用法如下:

    class ClassName {
       public void method() {
          synchronized(ClassName.class) {
             // todo
          }
       }
    }
    
    /**
     * 同步线程
     */
    class SyncThread implements Runnable {
       private static int count;
    
       public SyncThread() {
          count = 0;
       }
    
       public static void method() {
          synchronized(SyncThread.class) {
             for (int i = 0; i < 5; i ++) {
                try {
                   System.out.println(Thread.currentThread().getName() + ":" + (count++));
                   Thread.sleep(100);
                } catch (InterruptedException e) {
                   e.printStackTrace();
                }
             }
          }
       }
    
       public synchronized void run() {
          method();
       }
    }
    

    8.6 总结

    • 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。
    • 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。
    • 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

    相关文章

      网友评论

          本文标题:Java 中的锁

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