Java锁(一):volatile、synchronized详解

一、锁的基础知识

锁的类型

锁从客观上分为悲观锁和乐观锁。

  • 乐观锁:乐观锁是一种乐观思想,认为写少读多,遇到并发写的可能性比较低,读数据的时候认为别人不会修改,所以读的时候不会上锁,但是在写的时候会判断一下在此期间有没有别人去更新这个数据,采取的是先读取当前版本号,然后加锁操作,写完的时候读取最新版本号做记录的版本号做比较一样则成功,如果失败则重复读-比较-写的操作。Java中的乐观锁基本都是通过CAS操作实现的,java.util.concurrent.atomic包下的原子变量。CAS(compare and swap)比较交换是一种更新的原子操作,比较当前值和传入值是否一样,一样则更新,否则则失败。
  • 悲观锁:悲观锁就是悲观思想,认为写多且遇到并发性的可能性高,每次拿数据的时候都认为别人为修改,所以每次读写的时候都会上锁,这样别人想读写数据的时候都会block(阻塞)知道拿到锁。Java中悲观锁就是syschronizedAQS框架下的锁则是先尝试CAS乐观锁获取锁,如果获取不到,才会转为悲观锁,如ReentrantLock
Java中的锁

在Java中主要有两种锁加锁机制:

  • syschronized关键字修饰
  • java.util.concurrent.Lock,Lock是一个接口,有很多实现类比如ReentrantLock

二、volatile

可见性
public class VolatileTest {
    public static void main(String[] args) {
        final  VT vt = new VT();
        Thread thread01 = new Thread(vt);
        Thread thread02 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException ignore) { }
                vt.sign = true;
                System.out.println("vt.sign = true 通知 while (!sign) 结束!");
            }
        });
        thread01.start();
        thread02.start();
    }
}

class VT implements Runnable {
    public boolean sign = false;
    @Override
    public void run() {
        while (!sign) {

        }
        System.out.println("你坏");
    }
}

上面的代码是两个线程同时操作一个变量,程序希望当sign在线程Thread01被操作vt.sign = true时,线程Thread02输出你坏

实际上这段代码永远不会输出你坏,而是一直处于死循环。这是为什么呢?接下来我们一步步讲解验证。

我们把sign关键字加上volatile关键字。

public volatile boolean sign = false;

这个时候会输出你坏

volatile关键字是Java虚拟机提供的最轻量级锁的同步机制,作为一个修饰符出现,同来修饰变量,不含括局部变量,用来保证对所有线程可见性。

volatile关键字修饰时内存变化

当没有volatile关键字修饰的时候,Thread01对变量进行操作,Thead02并不会拿到最新值。

volatile关键字时内存变化

当有volatile关键字修饰的时候,Thread01对变量进行操作时,会把变量的变化强制刷新到主内存,Thread02获取值时,会把自己内存的sign值过期掉,从主内存读取最新的。

有序性

volatile关键字底层是通过lock指令实现可见性的,lock指令相当于一个内存屏障,保证以下三点:

  • 将本处理器的缓存写入主内存。
  • 重排序时不会把后面的指令重新排序到内存屏障之前。
  • 如果是写入操作会导致其他内存器中对应的内存无效。
总结
  • volatile关键字会控制被修饰的变量在内存操作的时候会主动把值刷新到主内存,JMM会先将线程对应的CPU内存设置过期,从内存读取最新值。
  • volatile关键字是通过内存屏障防止指令重排,volatile的内存屏障在读写的时候在前后各添加一个Store屏障来保证重新排序时不会把内存屏障后面的时候指令排序到内存屏障之前。
  • volatile不能解决原子性,如果需要解决原子性需要synchronized或者lock

三、synchronized

知识大纲

使用方法

synchronized关键字主要有以下三种使用方式:

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

    public class SynchronizedTest implements Runnable{
        private static int i = 0;
    
        public synchronized void getI(){
            if (i % 1000000 == 0) {
                System.out.println(i);
            }
        }
    
        public synchronized void increase() {
            i++;
            getI();
        }
    
        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                increase();
            }
            System.out.println(i);
        }
    
        public static void main(String[] args) {
            ExecutorService executorService = Executors.newCachedThreadPool();
            SynchronizedTest synchronizedTest = new SynchronizedTest();
            executorService.execute(synchronizedTest);
            executorService.execute(synchronizedTest);
            executorService.shutdown();
        }
    }
    

    最后结果输出:

    1000000
    1556623
    2000000
    2000000
    

    上述代码中,创建两个线程同时操作同一个共享资源i,且increase()get()方法加了synchronized关键字,表示当前线程的锁是实例对象,因为传入线程都是synchronizedTest对象实例是同一个,所以最终结果肯定能输出2000000,如果我们换种方式,传入不同对象,代码如下:

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        SynchronizedTest synchronizedTest01 = new SynchronizedTest();
        SynchronizedTest synchronizedTest02 = new SynchronizedTest();
        executorService.execute(synchronizedTest01);
        executorService.execute(synchronizedTest02);
        executorService.shutdown();
    }
    

    输出如下:

    1002588
    1641267
    1848269
    

    最终肯定不是期望的200000,因为synchronized修饰方法锁的是当前实例,传入不同对象实例线程是无法保证安全的。

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

    public class SynchronizedTest implements Runnable{
        private static int i = 0;
    
        public synchronized static void getI(){
            if (i % 1000000 == 0) {
                System.out.println(i);
            }
        }
    
        public synchronized static void increase() {
            i++;
            getI();
        }
    
        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                increase();
            }
            System.out.println(i);
        }
    
        public static void main(String[] args) {
            ExecutorService executorService = Executors.newCachedThreadPool();
            SynchronizedTest synchronizedTest01 = new SynchronizedTest();
            SynchronizedTest synchronizedTest02 = new SynchronizedTest();
            executorService.execute(synchronizedTest01);
            executorService.execute(synchronizedTest02);
            executorService.shutdown();
        }
    }
    

    输出如下:

    1000000
    1649530
    2000000
    2000000
    

    上述代码和第一段代码差不多,只不过increase()get()方法是静态方法,且也加上了synchronized表示锁的是当前类对象,虽然我们传入不同的对象,但是最终结果是会输出200000的。

  • 修饰语代码块,指定加锁对象,给对象加锁,进入同步方法前要获取给定对象的锁。

    public class SynchronizedTest02 implements Runnable{
        private static SynchronizedTest02 synchronizedTest02 = new SynchronizedTest02();
        private static int i = 0;
    
        @Override
        public void run() {
            // 传入对象锁当前实例对象
            // 如果是 synchronized (SynchronizedTest02.class) 锁当前类对象
            synchronized (synchronizedTest02){
                for(int j=0;j<1000000;j++){
                    i++;
                }
            }
        }
    
        public static void main(String[] args) throws Exception {
            Thread thread01 = new Thread(synchronizedTest02);
            Thread thread02 = new Thread(synchronizedTest02);
            thread01.start();
            thread02.start();
            Thread.sleep(3000);
            System.out.println(i);
        }
    }
    

    上述代码用锁修饰代码块,传入的是对象表示锁的是当前实例对象,如果传入是类表示锁的是类对象。

特性
原子性

原子性表示一个操作不可中断,要么成功要么失败。

synchroniezd能实现方法同步,同一时间段内只有一个线程能拿到锁,进入到代码执行,从而达到原子性。

底层通过执行mointorenter指令,判断是否有ACC_SYNCHRONIZED同步标识,有表示获取monitor锁,此时计数器+1,方法执行完毕,执行mointorexit指定,此时计数器-1,归0释放锁。

可见性

可见性表示一个线程修改了一个共享变量的值,其它线程都能够知道这个修改。CPU缓存优化指令重排等都可能导致共享变量修不能立刻被其他线程察觉。

synchroniezd通过操作系统内核互斥锁实现可见性,线程释放锁前必须把共享变量的最新值刷新到主内存中,线程获取锁之前会将工作内存中共享值清空,从主内存中获取最新的值。

有序性

程序在执行时,有可能会进行指令重排,CPU执行指令顺序不一定和程序的顺序一致。指定重排保证串行语义一致(即重排后CPU执行的执行和程序真正执行顺序一致)。synchronized能保证CPU执行指令顺序和程序的顺序一致。

public class LazySingleton {

    /**
     * 单例对象
     * volatile + 双重检测机制 -> 禁止重排序
     */
    private volatile static LazySingleton instance = null;

    /**
     *   instance = new LazySingleton();
     *   1. 分配对象内存空间
     *   2. 初始化对象
     *   3. 设置instance指向刚分配的内存
     *
     *   JVM和CPU优化, 发生了指令重排, 1-3-2, 线程A执行完3, 线程B执行第一个判断, 直接返回, 这个时候是	 *	 有问题的。
     *   通过volatile关键字禁止重排序
     * @return
     */
    public static LazySingleton getInstance(){
        if (null == instance) {
            synchronized (LazySingleton.class){
                if (null == instance) {
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}

synchronized的有序性是保证线程有序的执行,不是防止指令重排序。上面代码如果不加volatile关键字可能导致的结果,就是第一个线程在初始化的时候,设置instance执行分配的内存时,这个时候第二个线程进来了,有指令重排,在第一个判断的时候直接返回,就出错了这个时候instance可能还没初始化成功。

重入性

synchronized是可重入锁,允许一个线程二次请求自己持有对象锁的临界资源。

public class SynchronizedTest03 extends A {

    public static void main(String[] args) {
        SynchronizedTest03 synchronizedTest03 = new SynchronizedTest03();
        synchronizedTest03.doA();
    }

    public synchronized void doA() {
        System.out.println("子类方法:SynchronizedTest03.doA() ThreadId:" + Thread.currentThread().getId());
        doB();
    }

    public synchronized void doB() {
        System.out.println("子类方法:SynchronizedTest03.doB() ThreadId:" + Thread.currentThread().getId());
        super.doA();
    }
}

class A {
    public synchronized  void doA() {
        System.out.println("父类方法:A.doA() ThreadId:" + Thread.currentThread().getId());
    }
}

上面代码正常输入如下:

子类方法:SynchronizedTest03.doA() ThreadId:1
子类方法:SynchronizedTest03.doB() ThreadId:1
父类方法:A.doA() ThreadId:1

最后正常的输出了结果,并没有发生死锁,说明synchronized是可重入锁。

synchronized锁对象的时候有个计数器,记录线程获取锁的次数,在执行完对应的代码后计数器就会-1,知道计数器清0释放锁。

类型和升级

在介绍锁的类型之前先说一下什么是markwordmarkword是java对象数据结构中的一部分,markword数据在长度为32位和64位虚拟机(未开启压缩指针)中分别是32bit和64bit,它的最后两位bit是锁状态标志位,用来标记当前对象的状态,如下表示:

状态标志位储存内容
无锁(未开启偏向锁)01对象哈希码、对象分代年龄
偏向锁(开启偏向锁)01偏向线程id、偏向时间戳、对象分代年龄
轻量级锁00指向轻量级锁指针
重量级锁10指向重量级锁指针
GC标记11
偏向锁

偏向锁会偏向于第一个访问锁的线程,如果在运行过程中只有一个线程访问不存在多个线程争用的情况下,则线程是不需要触发同步的,这个时候就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁升级至轻量级锁。

UseBiasedLocking 是一个偏向锁检查, 1.6 之后是默认开启的, 1.5 中是关闭的,需要手动开启参数是 XX: UseBiasedLocking=false

偏向锁获取过程:

  1. 访问markword中偏向锁表示是否为1,锁标志位01,确认为偏向锁状态。
  2. 判断markword中线程id是否指向当前线程id,如果是则执行步骤5,如果不是则执行步骤3
  3. 如果markword中线程id未指向当前线程id,则通过CAS操作竞争锁。如果竞争成功,则指向当前线程id,执行步骤5,如果竞争失败,则执行步骤4。
  4. 如果CAS竞争锁失败表示有竞争,当到达全局安全点(safepoint)时获得偏向锁的线程会被挂起,偏向锁升级为轻量级锁并撤销偏向锁(撤销偏向锁是会导致stop the word,除GC所需的线程外,所有的线程都进入等待状态,直到GC任务完成),然后被阻塞在安全点的线程会继续执行同步代码。
  5. 执行同步代码。
轻量级锁

当锁是偏向锁的时候,在运行过程中发现有其他线程抢占锁,偏向锁就会升级成轻量级锁,其他线程会通过自旋的形式获取锁,不会阻塞,提高性能,缺点是循环会消耗CPU。

轻量级锁加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁状态标志位为01状态,是否为偏向锁为0),虚拟机首先将在当前线程的帧栈中建立一个名为索记录(Lock Record)的空间,用于储存锁对象目前的markword的拷贝,官方称之为 Displaced Mark Word
  2. 拷贝对象的markword到锁记录中。
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的markword更新指向锁记录的指针,并将锁记录里的owner指向对象的markword,如果更新成功则执行步骤4,否则执行步骤5。
  4. 更新成功表示这个线程就获取到了锁的对象,并且对象的markword锁标志位设置成00,表示此对象处于轻量级锁 状态。
  5. 如果更新失败了,说明虚拟机首先会检查对象的markword是否指向当前线程的栈帧,如果是说明当前线程已经获取到了这个对象的锁。如果不是则说明多个线程竞争锁,轻量级锁就会升级成重量级锁,锁标志的状态值变为10,markword中储存的就是指向重量级锁的指针,后面等待锁的线程会进入阻塞状态。
重量级锁

当偏向锁升级成轻量级锁时,其他线程会通过自旋的方式获取锁,不会阻塞,如果自旋n次都失败了,这个时候轻量级锁就会升级成重量级锁。

总结

synchronized的执行过程:

  1. 检查markword里面存储的是不是当前线程的id,如果是则表示当前线程处于偏向锁。
  2. 如果不是,则尝试使用CAS将当前线程的id替换markword,如果成功则表示当前线程获取锁,偏向标志位置为1。
  3. 如果CAS失败则说明发生竞争,撤销偏向锁,进而升级成轻量级锁,锁标志置为00。
  4. 当前线程使用CAS将对象的markword替换成锁记录指针,如果成功,则当前线程获取锁。
  5. 如果替换失败,表示其他线程竞争锁,当前线程遍尝试使用自选锁的方式来获取锁。
  6. 如果自旋成功获取锁则依处于轻量级锁。
  7. 如果自旋失败,则升级成重量级锁,锁标志置为10。
  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: volatileJava中的一个关键字,用于修饰变量。它的作用是告诉编译器,该变量可能会被多个线程同时访问,因此需要特殊处理,以保证线程安全。 具体来说,volatile关键字有以下几个特点: 1. 可见性:当一个线程修改了volatile变量的值,其他线程能够立即看到这个修改。 2. 有序性:volatile变量的读写操作会按照程序的顺序执行,不会被重排序。 3. 不保证原子性:虽然volatile变量能够保证可见性和有序性,但是它并不能保证多个线程同时修改变量时的原子性。 因此,如果需要保证原子性,需要使用synchronized关键字或者Lock接口来进行同步。 总之,volatile关键字是Java中用于保证多线程访问变量的安全性的一种机制,它能够保证可见性和有序性,但是不能保证原子性。 ### 回答2: Java中的volatile关键字是一种轻量级的同步机制,用于确保多个线程之间的可见性和有序性。它可以用于修饰变量、类和方法。 1. 修饰变量:当一个变量被volatile修饰时,它会被立即写入到主内存中,并且每次读取变量时都会从主内存中重新获取最新的值。这样可以保证多个线程操作同一个变量时的可见性和一致性。 2. 修饰类:当一个类被volatile修饰时,它的实例变量就会被同步,而且每个线程都会获取最新的变量值。这样可以保证多线程操作同一对象时的可见性和一致性。 3. 修饰方法:当一个方法被volatile修饰时,它的调用会插入内存栅栏(memory barrier)指令,这可以保证方法调用前的修改操作都已经被写入主内存中,而方法调用后的读取操作也会重新从主内存中读取最新值。这样可以确保多线程之间的调用顺序和结果可见性。 需要注意的是,volatile并不能完全取代synchronized关键字,它只适用于并发度不高的场景,适用于只写入不读取的场景,不能保证复合操作的原子性。 总之,volatile关键字在Java中具有广泛的应用,可以保证多线程之间的数据同步和可见性,但也需要谨慎使用,以免造成数据不一致和性能问题。 ### 回答3: Java中的volatile关键字意味着该变量在多个线程之间共享,并且每次访问该变量时都是最新的值。简单来说,volatile保证了线程之间的可见性和有序性。下面我们详细解释一下volatile的用法和作用。 1. 线程之间的可见性 volatile关键字保证了对该变量的读写操作对所有线程都是可见的。在没有用volatile关键字修饰变量的情况下,如果多个线程并发访问该变量,每个线程都会从自己的线程缓存中读取该变量的值,而不是直接从主存中读取。如果一个线程修改了该变量的值,但是其他线程不知道,那么可能导致其他线程获取到的数据不是最新的,从而引发一系列问题。而用了volatile关键字修饰该变量后,每次修改操作都会立即刷新到主存中,其他线程的缓存中的变量值也会被更新,从而保证了线程之间的可见性。 2. 线程之间的有序性 volatile关键字也保证了线程之间的有序性。多个线程并发访问同一个volatile变量时,JVM会保证每个线程按照程序指定的顺序执行操作。例如,在一个变量被volatile修饰的情况下,多个线程同时对该变量进行读写操作,JVM会保证先执行写操作的线程能够在后续的读操作中获取到最新的变量值。这么做的好处是,可以避免出现线程间操作顺序的乱序问题,从而保证了程序的正确性。 需要注意的是,并不是所有的变量都需要用volatile关键字修饰。只有在多个线程之间共享变量并且对变量的读写操作之间存在依赖关系的情况下,才需要使用volatile关键字。此外,volatile关键字不能保证原子性,如果需要保证操作的原子性,需要使用synchronized或者Lock等其他并发工具。 总之,volatile关键字是Java中非常重要的关键字之一,它可以在多个线程之间保证可见性和有序性,从而保证了程序的正确性。在开发过程中,我们应该根据具体情况来选择是否使用volatile关键字,以及如何使用它。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值