小言_互联网的博客

Synchronized深度刨析

291人阅读  评论(0)

并发编程中的三个问题

并发这玩意也不知道让多少新手村的伙伴退游,确实有难度,不过这样才有意思嘛!

可见性问题

什么是可见性:指一个线程对共享变量经行修改,另一个先立即得到修改后的最新值。

但是在并发中一个常见的问题就是无法保证可见性。也就是,假设多个线程并发执行,这个时候,如果对于共享变量,第一个线程拿到了值,后面的线程改变了这个值,前面的线程可能依然拿着旧值运算。

举个例子:一个线程获取boolean类型的标记flag经行while循环,另一个线程改变这个flag变量的值,前一个线程并不会停止循环。

private static boolean flag = true;     //共享数据
public static void main(String[] args) throws InterruptedException {
   
    Thread t1 = new Thread(() -> {
   
        while (flag){
   
            //这里什么都不做,这样一来,如果是这个线程执行就会进入死循环
        }
    });
    t1.start();

    Thread.sleep(1000); //记得要休眠,否则,很多时候都是t2执行,看不出效果

    Thread t2 = new Thread(() ->{
   
        flag = false;
        System.out.println("线程二改变了数据");
    });
    t2.start();
}

所以并发中如何保证可见性这就是一个问题。

原子性

什么是原子性:在一次或者多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有操作都不执行。

这么一想会发现和数据库中的事务的原子性简直是一模一样。不久几个操作要么都执行要么都不执行吗。

举个例子:

private static int num = 0;

public static void main(String[] args) throws InterruptedException {
   
    /**
     * 这个线程就是把这个数加到100
     */
    Runnable runnable = () -> {
   
        for (int i = 0; i < 100; i++) {
   
            num++;
        }
    };
	//用一个集合装载线程,方便中断主线程,避免提前结束
    ArrayList<Thread> runnables = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
   
        Thread thread = new Thread(runnable);
        thread.start();
        runnables.add(thread);
    }
    
	//每个线程调用join方法都可以用来阻塞主线程。
    for (Thread thread : runnables) {
   
        thread.join();
    }
    System.out.println(num);	//最后的结果很可能小于500
}

join方法的用法可以参考多线程常用API

这个例子中,num++可不是一步操作,编译之后看字节码文件(javap -p -v 字节码文件):

其中,对于num++ 而言(num 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic#12//Fieldnum:I
iconst_1
iadd
putstatic#12//Fieldnum:I

看到了吧,num++并不是一条语句。这样一来,如果是一个线程执行,到也没有什么问题。但是如果多线程执行,就可能,造成一个线程获得数据0,然后被中断,另一个获得0并加一,然后恢复,结果依然用0去加一,本应该为2的结果最后确定可能出现结果为1。

有序性

有序性应该很好理解,就是代码按照编写的顺序执行,但是呢,编译器可不一定按照你编写的代码进行编译!因为很多时候编译器会给你优化代码,可能就不是按照写的顺序进行编译。

举个例子:

private static boolean flag = false;     //共享数据
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
   
    Thread t1 = new Thread(() -> {
   
        if(flag){
   
            num = num + num;
        } else {
   
            num = 1;
        }
        System.out.println(num);
    });
    t1.start();

    Thread t2 = new Thread(() ->{
   
        num = 2;
        flag = true;
    });
    t2.start();
}

正常情况下,不论t1还是t2先执行,结果都不可能为0吧,但是如果编译后t2这两句因为谁先执行都不影响自己,所以有可能编译后执行顺序是这样的:

Thread t2 = new Thread(() ->{
   
    flag = true;
    num = 2;
});

也就是编译器有可能觉得这样编译效率更高,但是如果是这样,就可能出现结果为0!

这也就是并发中的有序性问题!

小结一下

总的来说,在多线程的情况下,基本上就是这几个问题,搞懂这些玩意,自然就可以搞懂并发这个玩意。

Java内存模型概述

冯诺依曼结构

在这之前,先看一下计算机的基本内存模型(作为一个学计算机的,这个必须知道)

提到这又又又又一次需要提到:冯诺依曼结构,即计算机由五大部分组成:

  • 输入设备(比如我们的键盘)
  • 输出设备(显示器,声音)
  • 存储器(内存)
  • 控制器
  • 运算器

后面的控制器和运算器是划分到CPU中。

基本知识

  • CPU:中央处理器,怎么运算,怎么控制,都是由这玩意搞。我们写的程序最后也会编程一条一条的指令,然后被CPU去执行,去处理数据。

  • 内存:我们跑起来的程序总需要有地方可以放吧,不就是放在内存中吗,电脑中的内存条,越大不就可以放更多的东西了吗,这不就是意味着后台可以开启的进程就越多吗。

  • 缓存:内存读取写入数据是有一个上限的,但是CPU执行的速度远比这个快,如果CPU都把一条指令执行完了,内存却还没有读取完下一条,这就浪费太多时间。这个时候缓存就来了,内存是单独的一的设备,缓存相当于在CPU自家开了一个小仓库,这样读取数据不需要每次跑到内存那么远去读。最靠近CPU的缓存称为L1,然后依次是L2,L3和主内存,CPU缓存模型如图下图所示:

一个处理的过程就是:

CPU先看L1中有没有数据,有就取出(也就是命中)经行运算,然后把处理的最新结果刷新到主存(也就是内存)中。如果没有命中,会依次到L2,L3,直到内存,然后把这个数据放一份到前面的缓存中。

Java中的内存模型

首先自行区分Java中的内存结构和内存模型这个概念Java内存模型的英文名称为Java Memory Model(JMM),其并不想JVM内存结构一样真实存在,而是一个抽象的概念。JMM和线程有关,它描述了一组规范或规则,一个线程对共享变量的写入时对另一个线程是可见的。Java多线程对共享内存进行操作的时候,会存在一些如可见性、原子性和顺序性的问题,JMM是围绕着多线程通信及相关的一些特性而建立的模型。

这张图中,就是描述每个线程都有自己的工作内存,这个部分独有,互不干扰,但是对于共享的主内存就可能出现各种并发问题。

  • 主内存:所有的线程共享。
  • 工作内存:每个线程都有自己的工作内存,工作内存只存储该线程对主内存的副本。线程对所有数据进行操作都是在自己的工作内存中完成的。

这个玩意就是synchronized和volatile这些东西可以控制并发安全的核心。

了解一下这个工作内存到主内存这个读写过程:

  1. 一个线程操作之前先对该数据加锁,防止别人乱搞(此时会清空工作内存中这个变量的值)
  2. 然后读取数据
  3. 把数据加载到工作内存
  4. 对数据操作
  5. 操作完了再写回主内存
  6. 释放这个锁(只有同步数据完成才可以释放)

Synchronized解决并发问题

这个关键字可以保证再同一时刻最多只有一个线程执行这段代码,这样就保证了并发情况下的安全问题。

synchronized(一把锁){
   
    //需要被保护的代码
}

保证原子性

还是上面的原子性的例子:

private static int num = 0;
private static Object lock = new Object();

public static void main(String[] args) throws InterruptedException {
   
    /**
     * 这个线程就是把这个数加到100,注意加锁了
     */
    Runnable runnable = () -> {
   
        for (int i = 0; i < 100; i++) {
   
            synchronized(lock){
   
                num++;     
            }
        }
    };
	//用一个集合装载线程,方便中断主线程,避免提前结束
    ArrayList<Thread> runnables = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
   
        Thread thread = new Thread(runnable);
        thread.start();
        runnables.add(thread);
    }
    
	//每个线程调用join方法都可以用来阻塞主线程。
    for (Thread thread : runnables) {
   
        thread.join();
    }
    System.out.println(num);	//最后的结果很可能小于500
}

这个时候无论怎么运行,结果都会是500。

进行反编译看这个结果:(会发现多了monitorenter这个指令)

怎么理解这个加锁过程:就是一个线程准备操作num这个变量,就会把这个东西占位独有,用自己定义的lock变量提示对方已经被我锁住了(相当于上厕所门把手,你进门会锁门,从外边看就变成了红色,别人就不会NT还去尝试开这门,而是在外边等着)。

所以再次理解这个原子性,这分明就是好几条指令,但是这些指令再某个时刻只能被一个人操作!

保证可见性

依然是上面的例子:

private static boolean flag = true;     //共享数据
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
   
    Thread t1 = new Thread(() -> {
   
        while (flag){
   
            synchronized (lock){
   
				//加个锁,
            }
        }
    });
    t1.start();

    Thread.sleep(1000); //记得要休眠,否则,很多时候都是t2执行,看不出效果

    Thread t2 = new Thread(() ->{
   
        flag = false;
        System.out.println("线程二改变了数据");
    });
    t2.start();
}

这个时候无论怎么运行都不会出现线程t1一直跑的情况。

那之前没加锁为什么会出现这种情况呢?了解完Java内存模型应该就明白了。

因为每个线程都是都是有自己的工作内存,假设线程t1先拿到数据flag,他就会复制到自己的工作内存,然后就死循环,因为他没对数据操作改变,就不会刷新主内存。然后t2拿到,改变了,刷新了主内存,但是对t1来说,他一直使用的都是之前复制的。

加了这个锁之后呢,每次循环我都要加锁,会对主内存操作(参考内存模型图),出了这个同步代码块又会释放锁。也会对主内存操作。然后如果t2改变了,这个时候t1由于死循环,会不断的重复这个代码块,也就是不断地去主内存刷新,自然能读取到t2改变的新数据。

保证有序性

上面提到过,编译器在编译的时候,不一定所有的代码都是按照我们写的方式经行编译。

但是像这样的代码一定会按照我们写的顺序执行:(有数据依赖关系)

//读后写,这个例子中,就必须按照这种顺序编译,否则就会结果出错
int a = 1;
int b = a;

//总之如果一些代码没有依赖关系,就有可能发生重排序。例如:
int a = 1;
int b = 2;
int c = a + b;
//这个也可能发生重排序,但是最多只能是a b互换,c必须是最后
int b = 2;
int a = 1;
int c = a + b;

然后使用synchronized是如何保证的呢:

private static boolean flag = false;     //共享数据
private static int num = 0;
private static Object o = new Object();
public static void main(String[] args) throws InterruptedException {
   
    Thread t1 = new Thread(() -> {
   
        synchronized(o){
   
            if(flag){
   
                num = num + num;
            } else {
   
                num = 1;
            }
        }
        System.out.println(num);
    });
    t1.start();

    Thread t2 = new Thread(() ->{
   
        synchronized(o){
   
            num = 2;
        	flag = true;
        }
    });
    t2.start();
}

这样一来,其实t2中的这两句依然有可能重排序,但是,不论怎么排序,这两句一定是被执行完了,才能轮到t1,或者说t1执行完了才能轮到t2,不存在,t2执行到重排序后的flag = true,然后t1执行,这样也就保证了整个过程结果的都是符合预期的!

Synchronized的特性

可重入性

先给出定义:指的是同一线程的外层函数获得锁之后,内层函数可以直接再次获得该锁。

public class ZiJie {
   
    public static void main(String[] args) {
   
        Runnable runnable = () -> {
   
            synchronized (ZiJie.class){
   
                System.out.println("第一层");
                synchronized ((ZiJie.class)){
   
                    System.out.println("第二层");
                }
            }
        };

        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}
//最后的结果一定是:
第一层
第二层
第一层
第二层

当然也可以这么写:(更加的符合实际一点)

public class ZiJie {
   
    public static void test(){
   
        synchronized (ZiJie.class){
   
            System.out.println("第二层");
        }
    }
    public static void main(String[] args) {
   
        Runnable runnable = () -> {
   
            synchronized (ZiJie.class){
   
                System.out.println("第一层");
                test();
            }
        };

        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}

这个是什么意思,好比你手里拿着一个本子,你第一次进入synchronized,拿到这个锁,你就记录这把锁数量为1,此时别人肯定拿不到,然后你再进一个,发现又是这把锁,然后就把这个锁数量变为2,这个时候即使你退出第一层synchronized,别人依然进不来,因为,你表示你还有一层没有出去。(也就是你本子上必须写了这个数量为0的时候,别人才可以使用)

总结:synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁.

可重入带来的好处:

  • 避免死锁

    避免死锁的原因: 如果synchronized不具备可重入性,当一个线程想去访问另一个方法时,它自身已经持有一把锁,而且还没有释放锁,又想获取另一个方法的锁,于是造成了永远等待的僵局,就会造成死锁。有了可重入性后,自己持有一把锁,并且可以直接进入到内层函数中,就避免了死锁。

    注意这是针对同一把锁。

  • 更好的封装代码

    编程人员不需要手动加锁和解锁,统一由JVM管理,提高了可利用性。

Synchronized可重入性的作用范围是整个获得锁的线程,线程内部的所有被调用的方法都共享该锁。

不可中断性

一旦这个锁被别人获得了,如果我还想获得,我只能选择等待或者阻塞,直到别的线程释放这个锁,如果别人永远不释放锁,那么我只能永远等下去

相比之下,Lock类,可以拥有中断的能力,第一点,如果我觉的我等的时间太长了,有权中断现在已经获取到锁的线程的执行,第二点,如果我觉的我等待的时间太长了不想等了,也可以退出

synchronized 的不可中断演示:

public static void main(String[] args) throws InterruptedException {
   
    Runnable runnable = () -> {
   
        synchronized (ZiJie.class){
   
            System.out.println(Thread.currentThread().getName()+"第一层");
            while (true){
   

            }
        }
    };

    Thread t1 = new Thread(runnable,"线程1");
    Thread t2 = new Thread(runnable,"线程2");
    t2.start();
    t1.start();
    Thread.sleep(200);
    //下面这两种一个处于RUNABLE,另一个处于BLOCKED
    System.out.println(t1.getState());	
    System.out.println(t2.getState());
}

查看源码如下:

public enum State {
   
    /**
     * Thread state for a thread which has not yet started.
     */
    NEW,

    /**
     * Thread state for a runnable thread.  A thread in the runnable
     * state is executing in the Java virtual machine but it may
     * be waiting for other resources from the operating system
     * such as processor.
     */
    RUNNABLE,

    /**
     * Thread state for a thread blocked waiting for a monitor lock.
     * A thread in the blocked state is waiting for a monitor lock
     * to enter a synchronized block/method or
     * reenter a synchronized block/method after calling
     * {@link Object#wait() Object.wait}.
     */
    BLOCKED,

    /**
     * Thread state for a waiting thread.
     * A thread is in the waiting state due to calling one of the
     * following methods:
     * <ul>
     *   <li>{@link Object#wait() Object.wait} with no timeout</li>
     *   <li>{@link #join() Thread.join} with no timeout</li>
     *   <li>{@link LockSupport#park() LockSupport.park}</li>
     * </ul>
     *
     * <p>A thread in the waiting state is waiting for another thread to
     * perform a particular action.
     *
     * For example, a thread that has called <tt>Object.wait()</tt>
     * on an object is waiting for another thread to call
     * <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
     * that object. A thread that has called <tt>Thread.join()</tt>
     * is waiting for a specified thread to terminate.
     */
    WAITING,

    /**
     * Thread state for a waiting thread with a specified waiting time.
     * A thread is in the timed waiting state due to calling one of
     * the following methods with a specified positive waiting time:
     * <ul>
     *   <li>{@link #sleep Thread.sleep}</li>
     *   <li>{@link Object#wait(long) Object.wait} with timeout</li>
     *   <li>{@link #join(long) Thread.join} with timeout</li>
     *   <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
     *   <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
     * </ul>
     */
    TIMED_WAITING,

    /**
     * Thread state for a terminated thread.
     * The thread has completed execution.
     */
    TERMINATED;
}

发现BLOCKED表示处于阻塞状态。

Lock锁演示

Lock是有两中的,一个是普通的lock方法,同synchronized一样是不可中断的。然后就是另一个tryLock,可中断锁。

public static void main(String[] args) throws InterruptedException {
   
    ReentrantLock lock = new ReentrantLock();
    boolean flag = true;
    Runnable runnable = () -> {
   
        lock.lock();
        System.out.println(Thread.currentThread().getName()+"执行中");

        while (flag){
   

        }
        lock.unlock();
    };

    Thread t1 = new Thread(runnable,"线程1");
    Thread t2 = new Thread(runnable,"线程2");
    t2.start();
    Thread.sleep(200);
    t1.start();
    t1.interrupt();
    Thread.sleep(200);
    //一个处于WAITING,另一个RUNABLE
    System.out.println(t1.getState());
    System.out.println(t2.getState());
}

如果使用tryLock(可中断),成功拿到返回true,这样即使别的线程没拿到锁,就会进入else分支!

public static void main(String[] args) throws InterruptedException {
   
    ReentrantLock lock = new ReentrantLock();
    boolean flag = true;
    Runnable runnable = () -> {
   
        boolean b = lock.tryLock();
        if(b){
   
            System.out.println(Thread.currentThread().getName()+"执行中");
            while (flag){
   

            }
            lock.unlock();
        }else{
   
            System.out.println("反正没拿到,干点别的事");
        }
    };

    Thread t1 = new Thread(runnable,"线程1");
    Thread t2 = new Thread(runnable,"线程2");
    t2.start();
    Thread.sleep(200);
    t1.start();
    Thread.sleep(200);
    System.out.println(t1.getState());	//TERMINATED
    System.out.println(t2.getState());	//RUNNABLE
}

Synchronized原理

使用Javap反汇编

我们可以看到,synchronized关键字编译后,会生成两条指令:monitorenter,monitorexit

monitorenter解释

官方解释如下:

每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。步骤如下:

  1. 每个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为 1 。
    • 当同一个线程再次获得该monitor的时候,计数器再次自增;
    • 当不同线程想要获得该monitor的时候,就会被阻塞。
  2. 当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。monitor将被释放,其他线程便可以获得monitor

总的来说,就是synchronized关键字的锁对象会关联一个monitor,这个并不是我们创建的,而是由JVM底层创建,这个对象有两个重要的成员变量:

  • owner,表示当前的对象所属的主人是谁
  • resursions,这个线程拥有的锁的次数,如果一个线程拥有一个monitor别的线程就只能处于等待状态。

monitorexit解释

能执行这个指令的线程一定是拥有当前monitor的线程。这个应该不难理解,必有只有你有这个锁才可以释放。

当线程执行monitorexit指令时,会去讲monitor的计数器减一,如果结果是0,则该线程将不再拥有该monitor。其他线程就可以获得该monitor了。

而且,monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。

同步方法

public synchronized static void test(){
   
    System.out.println(Thread.currentThread().getName()+"第二层");
}

对其进行反编译如下:

我们看到对于同步方法,反编译后得到ACC_SYNCHRONIZED 标志。

同步方法是隐式的。会隐式的调用monitorenter和monitorexit这两条指令。

小结

通过javap反汇编我们看到synchronized使用编程了monitorentor和monitorexit两个指令。每个锁对象都会关联一个monitor(监视器,它才是真正的锁对象),它内部有两个重要的成员变量owner会保存获得锁的线程,recursions会保存线程获得锁的次数,当执行到monitorexit时,recursions会-1,当计数器减到0时这个线程就会释放锁。

monitor监视器锁

上面中提到,不论怎么使用synchronized,都会涉及到monitor这个对象。

在HotSpot虚拟机源码中,monitor这个是由ObjectMonitor这个对象实现(C++写的)

可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。和万物皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。

每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

Java对象头

java的对象头由以下三部分组成:

  • Mark Word
  • 指向类的指针
  • 数组长度(只有数组对象才有)

Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。总之啊,这个对象头会记录一些状态的设置。

Synchronized优化

CAS

什么是CAS

CAS的全称为:Compare And Swap(比较相同再交换)。是现在CPU广泛支持的一种内存的共享数据经行操作的一种特殊指令。

CAS可以将比较和交换作为原子操作(这样就保证了线程安全),这个保证由CPU保证。CAS操作依赖三个值:

  • 内存中的最新值V
  • 旧的预估值X
  • 要修改的新值B

每次操作会比较X和V,只有相同才会把B保存到内存中。

CAS和volatile实现无锁并发

public class CAS {
   
    public static void main(String[] args) throws InterruptedException {
   
        AtomicInteger atomicInteger = new AtomicInteger();

        Runnable runnable = () -> {
   
            for (int i = 0; i < 100; i++) {
   
                atomicInteger.incrementAndGet();
            }
        };

        ArrayList<Thread> list = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
   
            Thread thread = new Thread(runnable);
            thread.start();
            list.add(thread);
        }

        for (Thread thread : list) {
   
            thread.join();
        }
        System.out.println(atomicInteger);
    }
}

源码角度解析

首先我哦们注意到我们使用的不是int,而是AtomicInteger,这是一个原子操作类,源码如下:(只看这是使用到的incrementAndGet方法,以及属性)

public class AtomicInteger extends Number implements java.io.Serializable {
   
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
   
        try {
   
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) {
    throw new Error(ex); }
    }

    //被volatile修饰
    private volatile int value;

    public AtomicInteger(int initialValue) {
   
        value = initialValue;
    }

    /**
     * Creates a new AtomicInteger with initial value {@code 0}.
     */
    public AtomicInteger() {
   
    }

	public final int incrementAndGet() {
   
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
}

[ PS ]:volatile修饰的关键字只要对这个值修改就会刷新主内存!

我们使用的就是这个默认的构造方法,也就是这个value默认是0,然后在并发中调用incrementAndGet方法,这个方法表示自增。然后注意Unsafe这个类:

Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题。过度的使用Unsafe类会使得出错的几率变大,因此Java官方并不建议使用的,官方文档也几乎没有。这个类你也无法直接调用,而只能通过反射获取。(这个类中的方法几乎都是调用的操作系统的源码,不受JVM管理)

然后我们自增的方法调用了这个类中的这个getAndAddInt方法:

public final int getAndAddInt(Object var1, long var2, int var4) {
   
    int var5;
    do {
   
        //获取预估值,这个值就是
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
//而这个比较交换调用的就是操作系统的方法
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

//注意在AtomicInteger这个类中的静态代码块中 valueOffset = unsafe.objectFieldOffset,这个方法同样是调用native方法,获取内存中的value值
public native long objectFieldOffset(Field var1);

然后这个比较交换的过程:(一定要仔细看,否则你可能懵逼了)

假设一个极端情况:(这里使用线程A B来表示)

A首先进到var5 = this.getIntVolatile(var1, var2);这句话,你会发现这个也是native方法,而且var2这个值的来源是AtomicInteger中的valueOffset(也就是获取内存中的值):

private static final long valueOffset;

static {
   
    try {
   
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) {
    throw new Error(ex); }
}

这个值是一个静态值,每次调用都会触发这个静态代码块,然后这个方法就是去内存中查找value这个值。

至此,A获取到默认值var5为0,最极端情况,A被中断,B有执行到A这个地方,也获取到这个值为0,然后进行compareAndSwapInt方法,var1和var2被本地方法调用获取最新值,然后和var5这个预估值经行比较,B此时发现相同,于是把预估值加上var4(看源码,就是unsafe.getAndAddInt(this, valueOffset, 1)传递过来的1),然后内存的最新值被设置为了1,此时A再进来执行,同样的取比较内存中的最新值和自己的预估值,发现不相等,此时再次进入循环,再获取预估值,而这个预估值不就是从内存中获取吗,就是新设置的值。然后A拿到新的值去比较,相等,再进行加1!

这就是整个CAS算法的过程,并不难。

乐观锁和悲观锁

悲观锁:总是觉得不够安全,每次获取数据怕别人修改,于是每次获取数据都会对这个数据加锁。这样一来,别人来操作这个数据就会进入阻塞。synchronized和ReentrantLock都是一种悲观锁。

乐观锁:总会觉得没有问题,自己拿了数据也不上锁,随便别人怎么操作,但是自己更新的时候都会判断一下看别人有没有改这个数据,改了则重新获取。CAS这种机制就是一种乐观锁。综合性能不错。

但是CAS获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下:

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
  • 如果竞争激烈,可以想到重试(获取最新值)必然频繁发生,反而效率会受影响。

锁升级过程

高效并发是从JDK 5到JDK 6的一个重要改进,HotSpot虛拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,包括偏向锁( Biased Locking )、轻量级锁( Lightweight Locking )和如适应性自旋(Adaptive Spinning)、锁消除( Lock Elimination)、锁粗化( Lock Coarsening )等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

整个过程:无锁——>偏向锁——>轻量级锁——>自旋锁——>锁消除——>锁粗化——>重量级锁

偏向锁

什么是偏向锁

再大多数情况下,锁不仅不存在多线程竞争,而且同一时间总是由同一个线程多次获得,为了让线程获得锁的带价更低,就引进了偏向锁。

偏向锁的意思就是这个锁会偏向于第一个获得他的线程,会在对象头存储锁偏向的线程的id,以后该线程进入和退出只需要检查是否为偏向锁,锁标志位以及线程id即可。

不过一旦出现多个线程竞争时必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前节省下来的CAS原子操作的性能消耗,不然就得不偿失了。

原理

偏向锁就是指对象头中的 mark word 存储了当前线程的 ID;

获取锁过程

  1. 检查 mark word 中的线程 id 是不是当前线程,如果是当前线程,进入同步代码块;不是就执行步骤 2;
  2. 进行 CAS 尝试将 线程 ID 换成自己;如果成功就执行代码块,不成功就执行步骤 3;
  3. 当拥有锁的线程到达安全点之后,挂起这个线程,进行锁升级 - 步骤 4;
  4. 锁升级,原持有偏向锁的线程,创建锁记录,将锁对象头拷贝到锁记录,唤醒持有锁的线程继续执行;然后释放轻量级锁 - 步骤 5
  5. 首先对比对象头中的锁记录指针是否指向当前线程的锁记录;再对比线程锁记录中的 mark word 是否和对象头的 mark word 一致;如果一致就释放锁,不一致则执行步骤 6
  6. 执行到这里,表示锁升级成重量级,释放锁,然后唤醒被挂起的线程。

好处

偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。

它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。

在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。但在应用程序启动几秒钟之后才激活,可以使用-XX:BiasedLockingStartupDelay=0参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过-XX:-UseBiasedLocking=false参数关闭偏向锁。

轻量级锁

什么是轻量级锁

轻量级锁是JDK 6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。

首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的。

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

原理

  1. 当线程尝试获取锁时,会在栈中创建一个锁记录,并把锁对象的 mark word 拷贝到锁记录中;
  2. 使用 CAS 尝试将锁对象的 mark word 更新为当前线程锁记录的指针,如果成功,表示持有锁,执行同步块,如果失败执行步骤 3
  3. 线程就会自旋,重复步骤 2;如果达到一定次数,没有获取成功,就执行步骤 4
  4. 锁升级,将线程锁对象头修改指向 monitor 的指针,然后继续执行代码块,释放重量级锁

小结

在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。

自旋锁

什么是自旋锁

monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。

同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁。

大白话就是,假设有个人上厕所,假设这个人还有10秒出来,而你走过去或者回来需要30秒,这个时候看到有人在厕所,你是等一下还是直接回去,这就是自旋锁,也就是我设定一个挣扎时间,再这个时间内我就不回去,一直等着,这个时间到了再回去。

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX : PreBlockSpin更改。

适应性自旋锁

在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

jvm 会根据上一次自旋的次数动态的调整自旋的次数,如果上一次自旋的次数少,表示线程自旋获取锁的概率大,jvm 会增加相应的次数,增加获取锁的概率,反之亦然;

一张图总结:

如何优化

减少synchronized的范围

同步代码块中尽量短,减少同步代码中的执行时间,减少锁的竞争。

尽量只锁住可能出现并发问题的地方。

synchronized(Demo.class){
   
	//必要部分
    a++;
}

//非必要部分
System.out.print(a);

减少synchronized锁的粒度

将一个锁拆分为多个锁提高并发度。

这一点很重要,因为JDK中改进的地方很多都是这么干的,比如我们知道HashMap本来是线程不安全的,但是HashTable是线程安全的:(直接锁住整个方法)

public synchronized V put(K key, V value) {
   
    // Make sure the value is not null
    if (value == null) {
   
        throw new NullPointerException();
    }
    ...
}

但是你发现了吗,所有的put操作,全部上锁:

后来的JUC(java.util.concurrent)迸发包中改进的ConcurrentHashMap如下:

public V put(K key, V value) {
   
    return putVal(key, value, false);	//调用下面这个方法
}

/** 这里是重点,顺便阅读一下源码 */
final V putVal(K key, V value, boolean onlyIfAbsent) {
   
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());	//计算hash值
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
   
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
   
            //当表为空的时候,进行CAS操作,存放这个数据。防止别人并发时候也在这个位置存放数据
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))	//下面的官方注释下都写着添加的时候表中无数据无锁。其实就是乐观锁
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
   
            V oldVal = null;
            
            //然后就是这里,这个f就是每一列的头!!!,也就是只锁住了每一列,对其他列不影响
            synchronized (f) {
   
                if (tabAt(tab, i) == f) {
   
                    ......//中间代码省略
        }
    }
    addCount(1L, binCount);
    return null;
}

最后用这张图来表示:

像这样的例子还有队列,普通的队列都是读写使用一把锁,这样你在读的时候我就什么也做不了,但是队列操作都是一头一尾,读写本就互相独立:

后的JUC中同样对这个进行了改进,LinkedBlockingQueue入队和出队使用不同的锁,相对于读写只有一个锁效率要高:

读取时不加锁,写入和删除时加锁:(这也就是读写分离)

ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet


转载:https://blog.csdn.net/weixin_55086330/article/details/115350176
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场