深入学习synchronized

synchronized

并发编程中的三个问题:

可见性(Visibility)

是指一个线程对共享变量进行修改,另一个先立即得到修改后的最新值。

代码演示:

public class Test01Visibility {
    public static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (flag) {

            }
        }).start();

        Thread.sleep(2000);

        new Thread(() -> {
            flag = false;
            System.out.println("修改了flag");
        }).start();
    }
}

小结:并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改

后的最新值。

原子性(Atomicity)

在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行

代码演示:

public class Test02Atomicity {
    public static int num = 0;
    public static void main(String[] args) throws InterruptedException {
        // 创建任务
        Runnable increment = () -> {
            for (int i = 0; i < 1000; i++) {
                num++;
            }
        };
        ArrayList<Thread> threads = new ArrayList<>();
        //创建线程
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(increment);
            t.start();
            threads.add(t);
        }
        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println(num);
    }
}

通过 javap -p -v Test02Atomicity对class 文件进行反汇编:发现++ 操作是由4条字节码指令组成,并不是原子操作

小结:并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作

有序性(Ordering)

是指程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。

代码演示:

@JCStressTest
@Outcome(id={"1","4"},expect=Expect.ACCEPTABLE,desc="ok")
@Outcome(id="0",expect=Expect.ACCEPTABLE_INTERESTING,desc="danger")
@State
public class Test03Orderliness { 
    int num=0;
    boolean ready=false;
    //线程一执行的代码
    @Actor
    public void actor1(I_Resultr){
        if(ready){
            r.r1=num+num;
        }else{
            r.r1=1;
        }
    }
    //线程2执行的代码
    @Actor
    public void actor2(I_Resultr){
        num=2;
        ready=true;
    }
}

运行的结果有:0、1、4

小结:程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必

就是开发者编写代码时的顺序。

Java内存模型(JMM)

计算机结构简介

根据冯诺依曼体系结构,计算机由五大组成部分,输入设备,输出设备,存储器,控制器,运算器。

CPU:

中央处理器,是计算机的控制和运算的核心,我们的程序最终都会变成指令让CPU去执行,处理程序中的数据。

内存:

我们的程序都是在内存中运行的,内存会保存程序运行时的数据,供CPU处理。

缓存:

CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。于是就有了在

CPU和主内存之间增加缓存的设计。CPU Cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,速度也更快,同时也代表着容量越小。

Java内存模型

Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,具体如下。

主内存

主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。

工作内存

每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。

小结

Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。

主内存与工作内存之间的交互

注意:1. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值

  1. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中

synchronized保证三大特性

synchronized保证可见性

while(flag){
    //增加对象共享数据的打印,println是同步方法
    System.out.println("run="+run);
}

小结:

synchronized保证可见性的原理,执行synchronized时,lock原子操作会刷新工作内存中共享变量的值。

synchronized保证原子性

for(int i = 0; i < 1000; i++){
    synchronized(Test01Atomicity.class){
        number++;
    }
}

小结:

synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块。

synchronized保证有序性

synchronized(Test01Atomicity.class){
    num=2;
	ready=true;
}

小结

synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码,保证有序性。

synchronized的特性

可重入特性

public class Demo01 {
    public static void main(String[] args) {
        new MyThread().start();
        new MyThread().start();
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        synchronized (MyThread.class) {
            System.out.println(Thread.currentThread().getName() + "获取了锁1");
            synchronized (MyThread.class) {
                System.out.println(Thread.currentThread().getName() + "获取了锁2");
            }
        }
    }
}

可重入原理:

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

可重入的好处:

  1. 可以避免死锁

  2. 可以让我们更好的来封装代码

小结:

synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁啦,获取一次锁加+1,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。

不可中断特性

什么是不可中断?

一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。

public class Uninterruptible {
    private static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException {
        Runnable run = () -> {
            synchronized (obj) {
                String name = Thread.currentThread().getName();
                System.out.println(name + "执行同步代码块");
                try {
                    Thread.sleep(888888);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        Thread t1 = new Thread(run);
        t1.start();
        Thread.sleep(1000);
        Thread t2 = new Thread(run);
        t2.start();

        Thread.sleep(1000);
        System.out.println("停止线程2前");
        System.out.println(t2.getState());
        t2.interrupt();
        System.out.println("停止线程2后");
        System.out.println(t1.getState());
        System.out.println(t2.getState());
    }
}

synchronized是不可中断,处于阻塞状态的线程会一直等待锁。

ReentrantLock可中断演示

public class Interruptible {
    private static Lock lock = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException {
        test01();
    }

    private static void test01() throws InterruptedException {
        Runnable run = () -> {
            boolean flag = false;
            String  name = Thread.currentThread().getName();
            try {
                flag = lock.tryLock(3, TimeUnit.SECONDS);
                if (flag) {
                    System.out.println(name + "获得锁,进入锁执行");
                    Thread.sleep(888888);
                } else {
                    System.out.println(name + "没有获得锁");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (flag) {
                    lock.unlock();
                    System.out.println(name + "释放锁");
                }
            }
        };

        Thread t1 = new Thread(run);
        t1.start();
        Thread.sleep(1000);

        Thread t2 = new Thread(run);
        t2.start();
    }
}

小结:

synchronized属于不可被中断

Lock的lock方法是不可中断的

Lock的tryLock方法是可中断的

synchronized 的原理

monitorenter:

每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:

  1. 若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者)

  2. 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1

  3. 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。

monitorenter小结:

synchronized的锁对象会关联一个monitor, 这个monitor不是我们主动创建的, 是JVM的线程执行到这个同步代码块,发现锁对象

有monitor就会创建monitor, monitor内部有两个重要的成员变量owner拥有这把锁的线程,recursions会记录线程拥有锁的次数,

当一个线程拥有monitor后其他线程只能等待。

monitorexit:

  1. 能执行monitorexit 指令的线程一定是拥有当前对象的monitor的所有权的线程。

  2. 执行monitorexit 时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权

monitorexit释放锁。

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

面试题synchroznied出现异常会释放锁吗?

:会释放锁。

同步方法

同步方法在反汇编后,会增加ACC_SYNCHRONIZED修饰。会隐式调用monitorenter 和monitorexit。在执行同步方法前会调用

monitorenter,在执行完同步方法后会调用monitorexit 。

小结:

通过javap反汇编可以看到synchronized 使用了monitorentor和monitorexit两个指令。每个锁对象都会关联一个monitor(监视

器,它才是真正的锁对象),它内部有两个重要的成员变量owner会保存获得锁的线程,recursions会保存线程获得锁的次数, 当执行到

monitorexit时, recursions会-1, 当计数器减到0时这个线程就会释放锁。

面试题:synchronized与Lock的区别

1、synchronized 是关键字,lock 是一个接口

2、synchronized 会自动释放锁,lock 需要手动释放锁。

3、synchronized 是不可中断的,lock 可以中断也可以不中断。

4、通过lock 可以知道线程有没有拿到锁,而synchronized 不能。

5、synchronized 能锁住方法和代码块,而lock 只能锁住代码块。

6、lock 可以使用读锁提高多线程读效率。

7、synchronized 是非公平锁,ReentrantLock 可以控制是否是公平锁。

CAS

cas的概述和作用:

compare and swap,可以将比较和交换转为原子操作,这个原子操作直接由cpu保证,cas可以保证共享变量赋值时的原子操作,cas依赖3个值:内存中的值v,旧的预估值x,要修改的新值b。根据atomicInteger的地址加上偏移量offset的值可以得到内存中的值,将内存中的值和旧的预估值进行比较,如果相同,就将新值保存到内存中。不相同就进行重试。

Java对象的布局

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:

HotSpot采用instanceOopDesc和arrayOopDesc来描述对象头,arrayOopDesc对象用来描述数组类型。

从instanceOopDesc代码中可以看到 instanceOopDesc继承自oopDesc。

_mark表示对象标记、属于markOop类型,也就是Mark World,它记录了对象和锁有关的信息

_metadata表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中Klass表示普通指针、compressed_klass表示压缩类指针。

Mark Word

锁状态 存储内容 锁标志位
无锁 对象的hashcode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 偏向线程id、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10

klass pointer

用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。通过-XX:+UseCompressedOops开启指针压缩,

在64位系统中,Mark Word = 8 bytes,类型指针 = 8bytes,对象头 = 16 bytes = 128bits;

实例数据

就是类中定义的成员变量。

对齐填充

由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,对象的大小必须是8字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对齐填来补全。

查看Java对象布局

<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.9</version>
</dependency>

小结

Java对象由3部分组成,对象头,实例数据,对齐数据,对象头分成两部分:Mark World + Klass pointer

偏向锁

什么是偏向锁?

锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。

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

偏向锁原理

当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:

  1. 虚拟机将会把对象头中的标志位设为“01”,即偏向模式。
  2. 同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

偏向锁的撤销

  1. 偏向锁的撤销动作必须等待全局安全点

  2. 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态

  3. 撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态

偏向锁是自适应的

小结:

偏向锁的原理是什么?

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的MarkWord之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

偏向锁的好处是什么?

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

轻量级锁

什么是轻量级锁?

轻量级锁是JDK 6之中加入的新型锁机制,轻量级锁并不是用来代替重量级锁的。

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

轻量级锁原理

当关闭偏向锁或多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

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

轻量级锁的释放:

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁时保存在Mark Word 中的数据;
  2. 用CAS 操作将取出的数据替换当前对象的Mark Word 中,如果成功,则说明释放锁成功。
  3. 如果CAS 操作替换失败,说明有其他线程获取该锁,则需要将轻量级锁膨胀升级为重量级锁。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS 操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

轻量级锁好处:

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

自旋锁

monitor 实现锁的时候, monitor 会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU 从用户态转为核心态,频繁的阻塞和唤醒对CPU 来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。同时,共享数据的锁定状态可能只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果有一个以上的处理器,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程“稍微等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否释放了锁。为了让线程等待,我们只需让线程执行一个循环(即自旋),这就是自旋锁。

自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX : PreBlockSpin来更改。

适应性自旋锁

在JDK 6 中引入了自适应的自旋锁。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

平时写代码如何对synchronized优化

减少synchronized的范围:

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

synchronized(Demo01.class){
    System.out.println("aaa");
}

降低synchronized锁的粒度:

将一个锁拆分为多个锁提高并发度,如HashTable:锁定整个哈希表,一个操作正在进行时,其他操作也同时锁定,效率低下。ConcurrentHashMap:局部锁定,只锁定桶。

读写分离:

读取时不加锁,写入和删除时加锁

ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet

posted @ 2020-11-23 22:12  西凉马戳戳  阅读(485)  评论(0编辑  收藏  举报