zoukankan      html  css  js  c++  java
  • Java线程专题 2:synchronized理解

    在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。

    测试代码:

    /**
    * @author MM
    * @create 2018-08-28 15:18
    **/
    public class SynchronizedDemo implements Runnable {
    private static int count = 0;
    static String lock = "xx";

    @Override
    public void run() {
    for (int i = 0; i < 10000; i++) {
    count++;
    }
    }

    public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
    Thread thread = new Thread(new SynchronizedDemo());
    thread.start();
    }
    try {
    Thread.sleep(500);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("count: " + count);
    }
    }

    这里整了10个线程,每个线程都累加了10000次,如果结果正确的话自然而然总数就应该是100000。但是运行结果不是这样,而且多次运行结果不一样。

    这里就出现了多线程安全问题。要保证正常结果需要同步处理。

      即:当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行

    在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),

    同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能),这点确实也是很重要的。

    synchronized关键字最主要有以下3种应用方式。

    • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

    • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

    • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

    修饰实例方法:

    public class SynchronizedDemo implements Runnable {
    private static int count = 0;
    // static String lock = "xx";
      //修饰实例方法
    public synchronized void add() {
    count++;
    }

    @Override
    public void run() {

    for (int i = 0; i < 10000; i++) {
    add();
    }
    }

    public static void main(String[] args) {
    SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
    for (int i = 0; i < 10; i++) {
    Thread thread = new Thread(synchronizedDemo);
    thread.start();
    }
    try {
    Thread.sleep(500);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("count: " + count);
    }
    }

    结果正确。

    修饰实例方法使用synchronized锁住的是实例对象,即每个线程都是对同一对象的实例方法作操作。如果把main中创建线程保持初识状态

    即Thread thread = new Thread(new SynchronizedDemo())则是不正确的。这里是不同的对象。

    修饰静态方法:

      

    public class SynchronizedDemo implements Runnable {
        private static int count = 0;
    //    static String lock = "xx";
    
        public synchronized static void add() {
            count++;
        }
    
        @Override
        public void run() {
    
            for (int i = 0; i < 10000; i++) {
                add();
            }
        }
    
        public static void main(String[] args) {
            SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
            for (int i = 0; i < 10; i++) {
                Thread thread = new Thread(new SynchronizedDemo());
                thread.start();
            }
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("count: " + count);
        }
    }

    当synchronized作用于静态方法时,其锁就是当前类对象锁(相当于synchronized(SynchronizedDemo.class))。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态 成员的并发操作。

    注意下:当作用于静态方法时,其他线程任然可以访问其他非静态方法(如果这里操作了需要同步的共享变量也会有线程安全问题)

    修饰代码块:

      

    public class SynchronizedDemo implements Runnable {
        private static int count = 0;
    //    static String lock = "xx";
    
    //    public synchronized static void add() {
    //        count++;
    //    }
    
        @Override
        public void run() {
            synchronized (this){
    
                for (int i = 0; i < 1000; i++) {
                    count++;
                }
            }
        }
    
        public static void main(String[] args) {
            SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
            for (int i = 0; i < 10; i++) {
                Thread thread = new Thread(synchronizedDemo);
                thread.start();
            }
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("count: " + count);
        }
    }
    上述的代码,使用了同步代码块的方式进行同步,传入的对象实例是this(对应当前实例)。每次当线程进入synchronized包裹的代码块时就会要求当前线程持有实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待。

    同步代码块是使用monitorenter和monitorexit指令实现的,JVM保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。
    线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权。
    锁优化
      在jdk1.6之后,对锁的实现上进行了大量优化,主要有自旋锁,锁消除,锁粗化,偏向锁,轻量级锁。
      锁的状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,四种状态会随着竞争的激烈而逐渐升级。
    自旋锁
      线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。
    同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
    所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
    自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。
    如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。
    所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。 自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,
    在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整; 如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。
    假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

    锁消除
      有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。 如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。
    变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?
    我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:
       public void vectorTest(){
            Vector<String> vector = new Vector<String>();
            for(int i = 0 ; i < 10 ; i++){
                vector.add(i + "");
            }
            System.out.println(vector);
        }
    

      这段代码明显在方法内部,不会有线程安全问题,jvm可以将vector 内部加锁操作消除

     锁粗化

      我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

    在大多数的情况下,上述观点是正确的,但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。 所谓锁粗化,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

    如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

    轻量级锁

      轻量级锁,也是JDK 1.6加入的新机制,之所以成为“轻量级”,是因为它不是使用操作系统互斥量来实现锁, 而是通过CAS操作,来实现锁。当线程获得轻量级锁后,可以再次进入锁,即锁是可重入(Reentrance Lock)的。

      加锁过程

    1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3)
    2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3)
    3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态

      释放锁过程

    1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据
    2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3)
    3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程

     偏向锁

      引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下: 

    获取锁

    1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
    2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
    3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
    4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
    5. 执行同步代码块

       

    释放锁

     偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

    1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
    2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;

    重量级锁

      重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

     
  • 相关阅读:
    一个网络传输框架——zeroMQ 调研笔记
    Node.js的cluster模块——Web后端多进程服务
    boost::spirit unicode 简用记录
    HTTP的长连接和短连接——Node上的测试
    MongoDB 驱动以及分布式集群读取优先级设置
    Lua知识备忘录
    MongoDB使用小结:一些常用操作分享
    此项目与Visual Studio的当前版本不兼容的报错
    @Controller和@RestController的区别
    SQLSERVER中计算某个字段中用分隔符分割的字符的个数
  • 原文地址:https://www.cnblogs.com/gcm688/p/9548955.html
Copyright © 2011-2022 走看看