Java与锁的一些简单总结
作者:大飞
- 前言
从开始写Java到现在,从开始不知道锁是什么,怎么用,更不知道为什么要用。到现在能够在必要的场景下正确的使用一些锁。这过程中经历了对锁的不断尝试和理解,这篇文章就来做一下Java里面关于锁的一些简单的总结。
有错误的地方请指正,有没提到的内容请补充!
- 在Java中,什么情况下需要使用锁?
首先我们可以回顾一下之前的编码经历,什么情况下要使用锁呢?抛开那些加锁方法和代码块,我们按照内存分布的方式来理解下,如果多个程序会访问同一块儿内存中的数据,这种情况下可能需要加锁。这块儿共享内存可以对应到我们Java中的全局变量、共享资源等等,总之就是大家(Java线程)共用的。
先举一个反例,看看下面的方法:
public static String getString(){ StringBuilder builder = new StringBuilder(); builder.append('s'); builder.append('h'); builder.append('i'); builder.append('t'); return builder.toString(); }
首先我们在方法中创建了一个实例,这个实例被分配到Java堆内存中。我们知道在Java内存分布中,栈内存是每个线程各自私有的,而堆内存是所有线程共用的,所以上面程序中的builder对应的对象处于所有线程共用的内存区域中,理论上可能被多个线程访问到。
但是,仔细思考一下,尽管builder对象被分配到了堆内存,但builder这个引用(类似句柄)只有在当前方法中有效。而且多个线程调用这个方法时,每个线程都会去new一个实例,都会有自己的builder引用(引用存在于线程的栈中),所以对于每个线程来说,只有它自己可以访问在堆内存中分配的builder,所以builder是不共享资源,上面的方法不需要加锁。
延伸:
这种情况也称为栈封闭,可以认为是线程安全的,不需要加锁。而且,这种情况下new的对象实例一定会分配在堆内存里面么?JVM中可能采取一些优化手段,比如逃逸分析(Escape Analysis),基于逃逸分析可能会进行栈上分配。也就是说,JVM会检测到,builder这个引用的生命周期只存在于上述方法范围中,不可能逃逸到方法外,所以可能就会直接将builder引用指向的对象分配到栈上了。
public static final Map<String, String> MAP; static{ Map<String, String> temp = new HashMap<String, String>(); temp.put("a", "1"); temp.put("b", "2"); MAP = Collections.unmodifiableMap(temp); } public static String getName(){ String px = "tim-"; return px + MAP.get("a"); }
很明显MAP是一个全局的共享资源,但是getName方法仍然不用加锁,为什么呢?因为虽然MAP是共享资源,但是它是不可变的,可能有多个线程访问它,但没有线程会修改它,所以这里也不需要加锁。
延伸:
我们会将这种不可变的域定义为常量,也就是说整个程序的运行过程中都不会去修改(写)这个常量。但是,就算是常量也要初始化吧,初始化也是写的过程,那怎么保证在初始化的时候不会有其他Java线程来读这个常量呢?所以一般常量都会由final修饰,可以保证安全发布(也就是说初始化过程中不会被其他线程读到),更多信息可以看下JSR-133中关于final域的重排规则。
public static final Map<String, String> MAP_U; static{ Map<String, String> temp = new HashMap<String, String>(); temp.put("a", "1"); temp.put("b", "2"); MAP_U = Collections.synchronizedMap(temp); } public static String getNameU(){ String px = "tim-"; return px + MAP_U.get("a"); }
很明显MAP_U是一个全局的共享资源,而且是可变的,我们不能保证其他线程不去修改MAP_U中的数据,所以访问这个共享数据的时候需要加锁。尽管我们方法中没有显示加锁,但MAP_U是一个线程安全的Map,get方法中已经加锁。
延伸:
尽管上面的MAP_U是线程安全的Map,但在某个复合操作下(比如判断没有则添加)还得额外加锁,如果需要原子的复合操作,请参见ConcurrenMap接口中提供的一些原子复合操作。
总结一下:当存在共享资源,且有线程会修改(写)这个共享资源时,那么对这个共享资源的访问(读写)都需要加锁。
- 如果获取锁失败,会有那些行为?
废话,获取锁失败后当然要等待了。
但具体怎么等待呢?有哪些细节?
我们知道Java中有很多锁,具体的等待细节也有所不同,但大体上等待方式可分两种:自旋等待和阻塞等待。从较底层的层面来说,自旋等待相当于当前的程序(进程)还在被调度执行,处理器还会执行程序的指令,但是这些指令表达的意思都是一直在不断(循环)尝试获取锁,直到获取成功,所以自旋等待也称为忙等待;而阻塞等待相当于当前程序不会被调度器调度了,处理器也不在执行它的指令了,一直到其他程序释放了锁,将其唤醒,它才会去再次尝试获取锁。
延伸:
Java线程一般都会映射到操作系统的进程,比如在Linux平台,Java线程会映射到Linux的线程(轻量级进程)。在Linux内核中,进程会由调度器来进行调度。这里简单说下CFS调度器,系统中所有可执行的进程在linux内核中组成一棵红黑树,CFS会从红黑树选择进程来调度。当进程阻塞后,会被从红黑树中转移到等待队列中,直到进程被唤醒,再次从等待队列中转移到红黑树中,才有可能再次被调度。
总结一下:如果程序获取锁失败,无外乎两种情况:程序继续被调度执行(不断重试);程序阻塞,不会被调度。
- 具体的锁是什么?
前面了解了锁的作用和一些行为,那么具体的锁是什么呢?
我们通过看一些Java中的锁机制来体会一下,首先最常用的就是synchronized关键字。
synchronized关键字给我们更多的感觉是语法层面的同步,只要有这个关键字,方法或者代码块儿中的代码就是线程安全的。但具体的锁是什么??
先看两个synchronized例子:private Map<String, String> cache = new HashMap<>(); public synchronized void put(String k, String v){ cache.put(k, v); } public void putV(String k, String v){ synchronized (cache) { cache.put(k, v); } }
其实我们关注的锁,是一个对象,这里就叫锁对象吧。我们知道,用synchronized修饰实例方法的话,就相当于synchronized(this);修饰静态方法的话,就相当于synchronized(this.class)。可见,synchronized相关的代码最后都可以归结为是synchronized(Object)的形式,那么这个Object其实就是锁对象。
一般锁对象可以是我们要访问的共享资源对象本身,也可以是专门定义的一个锁对象,总之得是一个公共的对象,所有访问其保护资源的线程都能访问到的对象。
好吧,看看下面这个程序有什么问题:public void putVV(String k, String v){ Object lock = new Object(); synchronized (lock) { cache.put(k, v); } }
其次,ReentrantLock也是比较常用的锁机制。
相比synchronized关键字,ReentrantLock更容易理解。它本身就是一个锁(锁对象),我们会自然而然的创建好这个锁对象,然后执行加锁解锁等操作,不会像使用synchronized那样有时候不知道自己在干啥。
最后,在Java中有时候会使用一些基于CAS操作的自旋锁机制。
这些操作其实也是基于对一个数值的CAS等操作来进行加锁解锁过程,这个数值就相当于是锁对象。
总结一下:具体的锁可以看成是一个对象,这个对象会被能访问由锁保护资源的所有线程访问到。
- Java中提供了哪些锁?
synchronized:
内置锁,可重入。内部做了一些细致的优化,获取锁过程为:偏向锁->轻量级锁->自旋锁->重量级锁。
ReentrantLock:
基于AQS的可重入锁,比synchronized更加灵活。
ReentrantReadWriteLock:
基于AQS的可重入的读写锁。
SequenceLock:
基于AQS的可重入的顺序锁,在乐观读方法上要比ReentrantReadWriteLock高效一些。(这个类在jsr166e的extra包里发现,但貌似没出现在jdk里)
StampedLock:
不可重入的优化读写锁,针对乐观读做了优化,一般用于构建内部并发组件。
cas:
程序中可以通过CAS操作来构建一些锁,比如jdk1.7中ForkJoin框架中使用的scanGuard包含的顺序锁、jdk1.8中Striped64的cellsBusy锁等。
基于AQS构建的同步机制:
这些同步机制和锁机制也有着很多联系。
总结一下:Java中提供了各种各样的锁,合适的场景使用合适的锁。
- 使用锁有哪些注意事项?
1.正确的使用锁。
该用的时候用,不该用的时候不用。不要因为确定不了是否会出现并发问题,就把所有的方法都加上锁,要仔细分析可能出现并发的地方,在需要的时候加锁;也不要意识不到并发问题,让一些被共享的资源在无锁保护下裸奔,常见的比如使用一个公共的Random实例。
2.使用合适的锁。
前面我们简单介绍了那么多锁,在实际使用时要使用合适的锁。
3.锁的一些优化。
尽量减少锁的范围,值锁可能产生并发问题的代码;尽量减少锁的粒度,避免一些不必要的竞争,比如ConcurrentHashMap中的方式;按照实际情况控制锁行为,比如实际等待锁的时间很短(就是说等待时间比上下文切换时间还短),就没有必要阻塞,可以自旋一下。如果自旋超过一定次数,都让其进入阻塞状态,避免消耗过多的处理器资源。
- JVM在这方面做了哪些优化?
1.锁消除。
看个例子:
public static String getStr(){ StringBuffer buffer = new StringBuffer(); buffer.append("1"); buffer.append("2"); buffer.append("3"); return buffer.toString(); }
前面已经提到过,这种情况属于栈封闭。同时我们知道,StringBuffer是一个线程安全的类,所有方法都是同步的。但很明显,这里的同步都是不必要的,所以JVM很可能会将代码中产生同步相关指令消除掉。
2.锁粗化。
public static StringBuffer getStr(){ StringBuffer buffer = new StringBuffer(); buffer.append("1"); buffer.append("2"); buffer.append("3"); return buffer; }
这种情况下,很明显buffer已经逃逸到方法外,没办法进行锁消除。但里面的3个append方法都会各自加锁解锁,实际上加一次锁(包含3个append)也可以,所以JVM很可能会将代码中的3个加锁解锁操作合并成一个。
3.偏向锁。
简单的说,就是在JVM内部,如果一个对象作为synchronized的锁对象,当一个线程获取这个锁时,会将线程id保存到这个锁对象的对象头上,当紧接着下一次申请获取这个锁的线程还是之前的线程时,只需要比较对象头中的线程id,不需要做其他锁相关的操作了。
相关推荐
20、EJB与JAVA BEAN的区别? Java Bean 是可复用的组件,对Java Bean并没有严格的规范,理论上讲,任何一个Java类都可以是一个Bean。但通常情况下,由于Java Bean是被容器所创建(如Tomcat)的,所以Java Bean应具有...
结合大量实例,全面讲解Java多线程编程中的并发访问、线程间通信、锁等最难突破的核心技术与应用实践 Java多线程无处不在,如服务器、数据库、应用。多线程可以有效提升计算和处理效率,大大提升吞吐量和可伸缩性,...
结合大量实例,全面讲解Java多线程编程中的并发访问、线程间通信、锁等最难突破的核心技术与应用实践 封底 Java多线程无处不在,如服务器、数据库、应用。多线程可以有效提升计算和处理效率,大大提升吞吐量和可...
线程安全性问题简单总结.mp4 线程之间的通信之wait notify.mp4 通过生产者消费者模型理解等待唤醒机制.mp4 Condition的使用及原理解析.mp4 使用Condition重写waitnotify案例并实现一个有界队列.mp4 深入解析...
简单的同步例子 一个网络服务器类 AsyncInputStream类 使用TCPServer和AsynclnputStream 总结 第六章 Java线程调度 线程调度概述 何时调度是重要的 调度和线程优先级 常见的调度实现 本地调度支持 其他线程调度...
Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性...
第28节线程安全性问题简单总结00:15:34分钟 | 第29节线程之间的通信之wait/notify00:32:12分钟 | 第30节通过生产者消费者模型理解等待唤醒机制00:20:50分钟 | 第31节Condition的使用及原理解析00:17:40分钟 | 第...
11.2.5 总结:类的修饰符 297 11.3 方法的修饰符 297 11.3.1 方法的访问控制符 298 11.3.2 public:没有限制的修饰符 299 11.3.3 protected:仅对子类和同包的类可见 300 11.3.4 默认控制符:仅在本包中可见 ...
第28节线程安全性问题简单总结00:15:34分钟 | 第29节线程之间的通信之wait/notify00:32:12分钟 | 第30节通过生产者消费者模型理解等待唤醒机制00:20:50分钟 | 第31节Condition的使用及原理解析00:17:40分钟 | 第...
11.2.5 总结:类的修饰符 297 11.3 方法的修饰符 297 11.3.1 方法的访问控制符 298 11.3.2 public:没有限制的修饰符 299 11.3.3 protected:仅对子类和同包的类可见 300 11.3.4 默认控制符:仅在本包中可见 ...
synchronized (关键字) 同步(锁) ['siŋkrәnaiz] Thread [java] 线程 [θred] throw (关键字) throws (关键字) [θrәu] 抛出(异常) transient (关键字) 瞬变;临时的['trænziәnt]'(可序列化) valid 正确的,...
第28节线程安全性问题简单总结00:15:34分钟 | 第29节线程之间的通信之wait/notify00:32:12分钟 | 第30节通过生产者消费者模型理解等待唤醒机制00:20:50分钟 | 第31节Condition的使用及原理解析00:17:40分钟 | 第...
看完《think in java》多线程章节,自己写的多线程文档,还结合了其他的相关网络资料。 线程 一. 线程池 1)为什么要使用线程池 2 2)一个具有线程池的工作队列 3 3)使用线程池的风险: 4 4)有效使用线程池的原则 5...
Java中的异常处理机制的简单原理和应用。 java socket java序列化 JVM加载class文件的原理 双亲委派模型 为什么要自定义类加载器 如何自定义类加载器 什么是GC 内存泄漏和内存溢出 Java的内存模型(JVM的内存划分) ...
第28节线程安全性问题简单总结00:15:34分钟 | 第29节线程之间的通信之wait/notify00:32:12分钟 | 第30节通过生产者消费者模型理解等待唤醒机制00:20:50分钟 | 第31节Condition的使用及原理解析00:17:40分钟 | 第...
2018/10/06 周六 下午 17:17 97,111 分布式锁简单入门以及三种实现方式介 绍.docx 2019/04/29 周一 下午 15:58 18,097 图灵群面试题.docx 2018/09/12 周三 上午 11:21 52,736 工厂模式实现.doc 2018/10/03 周三 下午...
Java 虚拟机面试题全面解析,《深入理解Java虚拟机》干货版,自己总结,希望能够帮助大家,免费下载~什么是类加载机制? 虚拟机和物理机的区别是什么? 运行时栈帧结构 Java方法调用 什么是方法调用? Java的方法调用,...
【数据库】乐观锁与悲观锁的区别 107 【数据库】数据库的三范式 107 【数据库】inner/left/right/full join的区别 109 【数据库】哪些字段该添加索引,哪些不添加? 109 【数据库】分页查询语句怎么写? 110 Mysql...