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,不需要做其他锁相关的操作了。
相关推荐
总结来说,Java中的线程同步和锁机制是保证多线程环境下数据一致性的关键工具。锁的粒度是控制同步范围的重要因素,它直接影响程序的并发性能。理解并合理运用这些知识点,对于编写高效、安全的多线程Java程序至关...
#### 五、Java线程:线程的同步与锁 1. **线程同步** - 线程同步是指控制多个线程对共享资源的访问,确保一次只有一个线程能够访问共享资源。 - 同步机制包括使用同步方法、同步代码块以及显式锁。 2. **锁** -...
总结来说,Java锁机制是通过控制线程对共享资源的访问来保证多线程环境下的数据一致性。`synchronized`提供了一种基础的锁机制,而`Lock`接口则提供了更灵活和强大的功能。理解并正确使用锁机制是编写高效、可靠的...
6. Java通过JDBC(Java Database Connectivity)与数据库进行交互。JDBC提供了一组API,使得Java程序可以连接到各种类型的数据库,执行SQL语句。使用JDBC时,需要引入java.sql包,加载相应的JDBC驱动,定义数据库...
总结来说,Java互斥锁是解决多线程环境下资源争抢和数据一致性问题的有效手段。通过`synchronized`关键字,我们可以确保在同一时间只有一个线程能够访问临界区(被同步的代码块),从而保证了线程安全。然而,过度...
Java提供了一些工具如jstack和VisualVM来诊断和解决死锁问题。 总结起来,Java线程涉及的内容广泛,包括线程的创建、控制、同步以及线程池的使用。理解这些知识点对于编写高效、安全的多线程Java应用程序至关重要。...
Java中的同步锁,即`synchronized`关键字,是Java多线程编程中用于解决并发问题的重要机制。它确保了对共享资源的互斥访问,防止数据的不一致性。当我们有多线程环境并涉及到共享数据时,可能会出现竞态条件,就像...
### Java精华总结 #### 一、Java概述与基础知识 ##### 1. 何为编程? 编程是一种通过编写计算机可以理解的指令来解决问题的过程。这些指令是按照特定的语法规则组织起来的,用来指导计算机执行特定任务。 ##### ...
### Java线程的同步与死锁 #### 一、引言 在Java中,多线程编程是一项重要的技术,能够显著提升程序的性能和响应能力。然而,随着线程数量的增加,线程间的同步问题变得越来越复杂。本文将深入探讨Java线程中的同步...
总结来说,`ReentrantLock`是Java并发编程中用于实现互斥锁的重要工具,提供可重入性、公平性和额外的控制功能,帮助开发者编写更安全、高效的多线程程序。在处理高并发场景或需要更高级别控制时,使用`...
在IT行业中,Redis是一个高性能的键值存储系统,常用于数据缓存、消息队列以及分布式锁等场景。...通过这个简单的Demo,开发者可以快速了解和掌握Java与Redis的交互方式,为后续的开发工作打下基础。
Java是一种广泛使用的面向...以上是对Java基础知识的总结,涵盖了逻辑操作符、接口与类的交互、Web开发技术、并发控制和企业级开发等内容,每个知识点都至关重要,理解并掌握它们是成为一名合格的Java开发者的基础。
### Java高级工程师面试总结 #### Java基础 - **Hashtable和HashMap的区别**: - `Hashtable`是线程安全的,而`HashMap`不是。这意味着在多线程环境中使用`Hashtable`时无需额外的同步措施,但这也使得其性能较低...
总结来说,Java多线程中的内置锁和显式锁各有优劣。内置锁简洁易用,但控制有限;显式锁提供了更多灵活性,但使用起来相对复杂。根据具体应用场景选择合适的锁机制,能有效提高并发程序的性能和可维护性。
### Java线程与模式总结 #### 一、Java线程简介 Java中的线程机制是其一大亮点之一,它直接支持线程级别的并发处理。线程相比于进程具有创建成本低、上下文切换快等优势,这使得Java在处理高并发场景时能够表现出色...
Redis中的分布式锁实现通常基于`SETNX`命令或`SET`命令的`nx`与`ex`组合。`SETNX`命令用于设置键值,但如果键已经存在,则不执行任何操作,这可以确保锁的互斥性。`SET key value EX timeout NX`则同时设置了超时...
【Java 多线程学习详细总结】 在Java编程中,多线程是处理并发执行任务的关键技术。本文将深入探讨Java中的多线程概念、实现方式、线程状态转换、线程调度、线程同步以及数据传递等相关知识。 1. **扩展`java.lang...