zoukankan      html  css  js  c++  java
  • Java 多线程应知应会

    请简单说说 synchronized 关键字的底层原理

    java 说到多线程绝对绕不开 synchronized,很多 java 工程师对 synchronized 是又爱又恨。为什么呢?主要原因包括以下几点:

    1. 在网上找到的各种学习资料,内容杂乱很多都是基于老版本写的,自己实践起来发现和网上说的不一样,不是那么回儿事儿。烦躁……

    2. 每次出去面试都会问这个问题,又没法直接看源码。烦躁

    3. 在小公司的开发同事们一定会发现,如果是做 javaWeb 项目的,在实际工作中很少会遇到多线程的问题。因为数据量小,请求数量小等各种原因。

    所以经过这段时间的学习总结(瞎看,瞎扒拉),我想在这里简单输出一下我对 synchronized 关键字的底层原理的理解。

    monitor 计数器

    这里先声明一个前提,synchronized 是可重入锁,也就是说已加锁的对象可以再次被获取到锁的线程再次加锁。是不是有点绕嘴,看看下面这段代码:

    public class SynchronizedDemo {
    
    
        public static void main(String[] args) {
            SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
            synchronizedDemo.test();
        }
    
        public synchronized void test() {
            System.out.println("来一把锁");
            test1();
        }
    
        public synchronized void test1() {
            System.out.println("再次加锁");
        }
    }
    
    /* output 来一把锁 再次加锁*/
    

    简单来解释一下这段代码。我们创建了一个对象 synchronizedDemo,然后调用方法 test,由于 synchronized 修饰了该方法,所以我们将对象 synchronizedDemo 进行了加锁。然后,test 方法内部又调用了 test1 方法,这个时候我们发现 test1 也是 synchronized 修饰的,所以我们再次对 synchronizedDemo 进行了加锁,这是对该对象的第二次加锁。

    这里其实体现了 synchronized 是可重入锁的特性。广义上说可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。

    好了言归正传,synchronized 是如何做到的呢?

    简单来说其底层有一个 monitor 计数器,当当一个线程第一次获取到对象的时候,会将对象头中的计数器改为 1,在加锁周期内再次加锁的话,那么就在原有的基础上再+1,以此类推。这是怎么回事儿呢?

    可以这么理解 test 方法是这么执行的

    1. 现场会首先判断synchronizedDemo 对象是否已经被加密了,也就是计数器是否为 0

    2. 如果已经是 1 了,那说明这个对象已经被其他线程占有了,当前线程无法获取这个对象,这个时候只能等待

    3. 如果计数器为 0,说明这个对象当前没有别的线程在使用,当前线程就可以对其进行加锁。monitor 计数器+1(从 0 变成 1)

    4. 如果加锁方法中还调用了其他加锁方法,每次执行一个加锁方法嵌套都会使monitor 计数器+1,方法执行完成之后再-1.

    5. 最终synchronized 修饰的方法执行完毕之后,对象的 monitor 计数器为 0,等待其他线程使用。

    这样说好像还是有点模糊,我在这里简单抽象的模拟一下这个过程大致是这样的:

    monitorenter  +1
      test();
    		monitorenter +1
          test1();
    		monitorexit; -1
    monitorexit; -1
    

    当执行test 方法之前,monitorenter 将计数器+1(这个时候计数器的值是 1,获取到这个对象之前,对象的计数器一定是 0,否则获取不到),然后 test 方法中又调用了 test1 方法,而这个方法也是被 synchronized 修饰的,那么会再次执行monitorenter将计数器加 1(这个时候计数器的值为 2)。当 test1 方法执行完之后,monitorexit 会将计数器的值-1(这个时候就是 1 了,2 - 1 = 1),然后 test 方法执行完了,monitorexit 将计数器的值再-1,当这个时候计数器的值就是 0了。也就是锁已经被释放,这个对象的锁可以继续被其他线程获取了。

    synchronized 锁方法是锁的什么?

    相信大家都知道 synchronized 可以对 对象和方法进行加锁。

    	Map<String, Object> map = new HashMap<>();	
    	// 修饰方法
    	public synchronized void test() {
            System.out.println("来一把锁");
           // 锁对象
            synchronized (map) {
                System.out.println("对 map 对象进行加锁");
            }
            test1();
        }
    

    看到这里集合上面说的计数器,就会有同学提出疑问了。

    不是说计数器在对象头里面存储的吗?那方法加锁是针对哪里加的锁啊?先说结论:对方法加锁,锁是还是加载对象上的,哪个对象调用的这个方法,就是在哪个对象上加锁。

    举个例子:

    public class SynchronizedDemo {
    
    
        HashMap<String, Object> map = new HashMap<>();
    
        public static void main(String[] args) {
            SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
            synchronizedDemo.test1();
        }
    
    
        public synchronized void test1() {
            System.out.println("再次加锁");
        }
    
    }
    
    

    这里可以看到 test1 方法被 synchronized 修饰了,我们加锁是记载 synchronizedDemo 对象上的,是这个对象调用 test1 方法。所以是对他进行加锁的。

    简单说说 CAS的理解

    像 synchronized 这种独占锁属于悲观锁,它是在悲观的任务加锁的这个地方一定会发生冲突。除了悲观锁之后,还有乐观锁,乐观锁的含义就是我乐观的认为这个的地方不会发生冲突,如果没有发生冲突我就正常执行,如果发生了冲突,我就重试。

    CAS 就属于乐观锁。

    为了方便理解 CAS,我们说个典型的例子。假设多个线程执行这个方法increment,势必会发生线程安全问题。因为 i++不是原子性操作,而且 increment方法没有加锁。

    public class CASDemo {
    
        int i = 0;
        public void increment() {
            i++;
        }
    
    }
    

    解决方法有两种,第一个肯定是刚才我们说的通过 synchronized 来加锁。

    public class CASDemo {
    
        int i = 0;
        public synchronized void increment() {
            i++;
        }
      
    	  public static void main(String[] args) {
            CASDemo casDemo = new CASDemo();
          	casDemo.increment();
        }
    }
    
    
    

    这里就是对 casDemo进行加锁,只有一个线程可以成功的对casDemo进行加锁,可以对他关联的monitor 计数器+1,加锁。其他线程就会等待这个对象被释放。这样的画出就是多个线程在这变成了串行化,效率会有损耗。多个线程在这排队。

    第二个办法就是将 i++变成原子性操作,如何做到呢 java.util.concurrent.atomic包中带有大量原子性的对象,比如 AtomicInteger。

    public class CASDemo {
    
        AtomicInteger i = new AtomicInteger(0);
    
        public void increment() {
            i.incrementAndGet();
        }
    
    }
    

    由于 increment 方法只有一行命令,而且这个方法还是原子性的,那么这个方法自然不存在线程安全问题。

    看到这里很多哥们就会问了,你不是说 CAS 吗,怎么扯到这个了?别着急啊,前面都是铺垫,我这不是正要说了嘛。

    其实 incrementAndGet 就是一个 CAS 操作。CAS 的全称是 compare and set ,比较并替换。CAS的思想很简单:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。其业务逻辑原理如图所示

    CAS 存在的问题

    1. ABA问题

    CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。这就是CAS的ABA问题。 常见的解决思路是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。 目前在JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

    1. 循环时间长开销大

    上面我们说过如果CAS不成功,则会原地自旋,如果长时间自旋会给CPU带来非常大的执行开销。

    参考资料《Java CAS 原理解析》

    互联网Java工程师面试突击(第三季)

    《Java 并发变成实战》


    未完待遇

  • 相关阅读:
    (二分查找 拓展) leetcode 69. Sqrt(x)
    (二分查找 拓展) leetcode 162. Find Peak Element && lintcode 75. Find Peak Element
    (链表) lintcode 219. Insert Node in Sorted Linked List
    (二分查找 拓展) leetcode 34. Find First and Last Position of Element in Sorted Array && lintcode 61. Search for a Range
    (最短路 Floyd) P2910 [USACO08OPEN]寻宝之路Clear And Present Danger 洛谷
    (字符串 数组 递归 双指针) leetcode 344. Reverse String
    (二叉树 DFS 递归) leetcode 112. Path Sum
    (二叉树 DFS 递归) leetcode 101. Symmetric Tree
    (二叉树 递归) leetcode 144. Binary Tree Preorder Traversal
    (二叉树 递归 DFS) leetcode 100. Same Tree
  • 原文地址:https://www.cnblogs.com/joimages/p/12034752.html
Copyright © 2011-2022 走看看