目录
前言:
学习编程,避不开多线程开发,多线程提供了高效的同时,也带来了访问资源的同步和互斥。互斥是指在一个单位时间内,只有一个线程可以访问共享资源。而同步是指多个线程之间可以同时正确的访问共享资源。在Java中,如何保障资源在访问过程中的原子性,正确性,就出现了锁机制。而锁的应用也比较常见,比如文件,数据的读写,对于一些接口要求不能并发请求等。
下面我们从一个例子,认识Java的锁:
package com.tiancity.dom.lib;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class test {
//一把锁
static Lock lock = new ReentrantLock();
public static void main(String[] args) {
for(int i = 0;i<4;i++){
final int finalI = i;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
lockTest(finalI);
}
});
thread.start();
}
}
public static String getDateFormat(Date date) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.CHINA);
return simpleDateFormat.format(date);
}
public static void lockTest(int id){
System.out.println(getDateFormat(new Date())+"准备执行"+id);
lock.lock();
try {
System.out.println(getDateFormat(new Date())+"开始执行"+id);
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
System.out.println(getDateFormat(new Date())+"执行结束"+id);
lock.unlock();
}
}
}
执行结果如下:
在上述代码中,我们通过ReentrantLock构建了一把重入锁,紧接着,我们通过一个循环,构造了4个Thread线程,在每一个线程执行阶段加锁,执行结束阶段释放锁,通过执行结果,我们可以看到执行和结束之间睡眠的这2秒,是没问题的。由此,ReentrantLock锁机制是生效的。那么,关于重入锁,我们可以干什么呢?下面我们将进入到源码(JDK1.8)里寻找答案。
从上述图中可以看到,ReentrantLock 是一个可重入互斥锁,关于互斥的概念,在多个线程当中,只允许一个线程访问共享资源,其他线程要想访问,只有等待当前线程释放锁。
除此,与监视器锁相同,什么又是监视器锁呢?经过查找资料,发现监视器锁其实是系统级的一把锁。而Java当中的关键字synchronized,也是由此实现的。ok,明白了,ReentrantLock和synchronized本质上是一样的。只不过ReentrantLock相比synchronized具有扩展功能。
既然,ReentrantLock相比synchronized具有扩展功能,那么,我们看看ReentrantLock有那些扩展功能呢。根据解释,ReentrantLock类的构造函数可以接收一个可选的公平性参数,如果设置为ture,意味着它是一个公平锁,即,多个线程在争用共享资源时,有利于等到时间最长的线程优先获得锁,从而获得资源。 相反,如果设置为false,意味着它是一个非公平锁,不能保证任何的特定顺序,怎么理解?多个线程在访问资源时,如果线程被设置为非公平锁,即得到资源的顺序是随机的。既然是随机的,那么当有一个线程一直获取不到锁时,就会出现线程的饥饿。
同时,ReentrantLock也是一种递归锁。
根据上述例子,我们使用了ReentrantLock,结合源码分析,ReentrantLock实现了Lock接口,Lock接口比较简单,包含5个方法。
public interface Lock {
//获取锁,可能发生异常,需要try-catch
void lock();
//获取锁,避免死锁,抛出异常
void lockInterruptibly() throws InterruptedException;
//如果锁可用,返回true,否则,返回false
boolean tryLock();
//给定等待时间内获取锁,返回true,否则经过线程调度,此线程休眠,直到获取锁,或者当前线程被中断
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//释放锁
void unlock();
}
关于Lock接口的解释,我们可以看到,在Java中,锁的概念分为了三大类,重入锁,监视器锁,读写锁 ,关于我在写这篇博客的时候,也有借鉴一些网上的资料,如《深入理解Java的16种锁》,且不说,有没有这么多锁,当我看到16种的时候,头就已经大了,可能博主确实理解了这么多的锁,但对我来说,真的很难看一遍全都理解。索性,就自己结合资料和源码去进行一次学习,毕竟,我们要善于站在巨人的肩膀上学习。
好了,从Lock的实现类来看,分别是ReentrantReadWriteLock,Segment,ReentrantLock,不难理解,分为读写锁,Segment(也叫分段锁,由16个Hashmap组成Segment对象,同时,Segment继承自ReentrantLock),和最后我们使用多个可重入互斥锁。实际上,也就是读写锁和重入锁
分类(16种):
我们从前言可知,Java里锁分为读写锁,重入锁,监视器锁。那关于递归锁,公平锁,非公平锁,乐观锁,悲观锁等等网上流传的16种锁,又是怎么划分呢?下面我们来认识一下。
读写锁
读写锁是通过ReentrantReadWriteLock这个类来实现,在Java里面,为了提高性能而提供了这把锁,读的地方用读锁,写的地方用写锁,读锁并不互斥,读写互斥,这部分直接由JVM进行控制。
在编码上,需要手动进行区分,下面的代码可以看到实现方式
// 创建一个读写锁
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 获取读锁
rwLock.readLock().lock();
// 释放读锁
rwLock.readLock().unlock();
// 创建一个写锁
rwLock.writeLock().lock();
// 写锁 释放
rwLock.writeLock().unlock();
乐观锁
乐观锁是一种设计思想,这种思想认为,在数据读写的过程中,读的操作远大于写的操作,因此
只需对写操作加锁,判断写入的值和期望值相同的时,进行更新,从而保证数据的原子性
悲观锁
悲观锁是指,遇到事情总做最坏的打算,是一种悲观思想。因此,无论是数据读还是数据写,通通上锁,其余线程拿不到锁,即都会被阻塞。
在Java中,悲观锁的实现方式有两种:synchronized,ReentrantLock
自旋锁
我们得知,当使用悲观锁,对线程里的资源进行加锁时,其余线程即会进入阻塞态,由线程的三态模型(也可扩展为五态模型)我们可知,线程的阻塞到执行,中间会有就绪态,当线程数多,频繁挂起,执行,那么势必会浪费性能。那么有没有一种锁,能减少线程间的频繁挂起和执行呢。
三态模型
五态模型
我们认识一下自旋锁:
自旋锁的原理是,为了不让线程阻塞,不断的执行一个空操作的循环,这样子避免了频繁的挂起和执行。
既然有优点,那自旋锁的缺点是什么呢?当执行空操作的循环时间大于频繁挂起到执行的这段时间时,不是就意味着不但没有减少性能损耗,反而还增加了机器压力嘛?因此,线程便会进入阻塞-再到就绪-执行。
默认值:JVM默认值10次,配置参数为:-XX:PreBlockSpin
由此,我们可以将自旋锁归类到监视器锁当中
递归锁
任何获取锁的线程可以再次获得该锁而不会阻塞,判断获取锁的线程是否是占据锁的线程,如果是则获取成功,进行自增。
优点:避免死锁。
在Java中,递归锁的实现方式有两种:synchronized,ReentrantLock
根据源码分析,属于重入锁的一种。
公平锁
公平锁:多个线程在争用共享资源时,有利于等到时间最长的线程优先获得锁,从而获得资源
等待时间最长的实现方式,可申请一个锁的队列,按照FIFO
在Java中,可重入锁ReentrantLock可以通过构造函数传参设计公平锁还是非公平的。
根据源码分析,属于重入锁的一种。
非公平锁
非公平锁,多个线程在访问资源时,如果线程被设置为非公平锁,即得到资源的顺序是随机的。
缺点:既然获取资源的线程是随机的,那么就会导致某个线程一直获取不到资源,从而产生线程饥饿
在Java中,synchronized的默认实现就是非公平锁
根据源码分析,属于重入锁的一种。
共享锁
共享锁本质上是乐观锁,读写锁的一种扩展用法,即线程之间通过协作能正确访问到同享资源,Java中的共享锁由ReentrantReadWriteLock实现
独占锁
独占锁本质上是悲观锁,也叫互斥锁,即只允许一个线程访问共享资源,其他线程则阻塞。
Java种的独占锁由ReentrantLock和synchronized实现。
重量级锁
重量级锁其实是一种称呼,synchronized就是一种重量级锁,它是通过内部一个叫做监视器锁来实现,而监视器锁本质上是依赖于系统的Mutex Lock(互斥锁)来实现,当加锁的时候需要用用户态切换为核心态,这样子的成本非常高,因此这种依赖于操作系统Mutex Lock的锁称为重量级锁。为了优化synchronized的性能,引入了轻量级锁,偏向锁
轻量级锁
在JDK1.6时,为了优化重量锁对于线程访问资源的互斥消耗,引入了CAS操作以便在没有竞争的时候通过操作系统直接访问到资源。
优点:线程之间不存在竞争的时候,提高了效率。
缺点:线程之间存在竞争,不但存在重量级锁,还增加了CAS操作的消耗。
CAS全称compare and swap,JDK提供的非阻塞原子性操作,它通过硬件保证了更新操作的原子性
允许多个线程访问共享资源,但是同一时间内只允许一个,其他线程不会阻塞而是重新尝试。
偏向锁
为了优化重量锁带来的消耗,在JDK1.6除了通过CAS操作进行优化外,还有一种时偏向锁,即偏心与第一个获得该锁的线程,如果后续没有其他线程请求该锁,则拥有该锁的线程可以直接访问资源。
优点:避免了硬件上的CAS操作,达到优化重量锁的目的
缺点:如果存在多个获取该锁的线程,则偏心被置换,永远都有第一个。
分段锁
认识分段锁,我们可以回归到JDK源码当中,之前我们分析过,分段锁是通过ConcurrentHashMap实现,由16个HashMap构成Segment对象,当存储key-value时,并不是将整个HashMap锁住,而是先进行hashcode计算从而得出这个key-value应该放在哪个HashMap里面,然后开始对该HashMap进行加锁,并完成put操作。而Segment类又继承ReentrantLock,由此分段锁也是重入锁的一种扩展。
同步锁
讲到同步锁,我们先来复习一下线程同步和互斥。
线程同步是指:在互斥的基础上,引入其他机制,可以做到各线程有序且不影响其他线程的情况下,完成多线程之间的资源访问。
既然认识了线程的同步和互斥,那么对线程之间的加同步锁,无疑就是想要保证多线程访问资源的有序性或者正确性。
乐观锁,自旋锁,公平锁,共享锁,偏心,轻量级,分段锁都是通过一些机制达到线程间访问资源的有序性和正确性,都可以归类到同步锁当中。
在Java中,ReentrantLock(true),Segment,都可以称之同步锁。
互斥锁
同理:
线程互斥是指:某一个资源只允许被一个线程所访问,其他线程只能被阻塞。互斥具有唯一性,排他性。且访问资源是随机的,无序的
悲观锁,独占锁,非公平锁,重量级锁都可以归类到互斥锁中
在Java中,synchronized,称之为互斥锁。
死锁
死锁不是一种锁,它是一种状态,即线程A持有资源a,线程B持有资源b,线程A等待线程B释放b,线程B等待线程A释放a,两个线程陷入死循环的状态。
避免死锁的方式,有:银行家算法
总结:
通过上述知识点,我们认识了Java中的重入锁,监视器锁,读写锁,又依靠解释,扩展出不同用法的锁并将其归类,差不多有10多种变化,但是,本质上,锁机制都是为了保证资源在多线程访问当中的正确性,不同场景,应用不同罢了。除了认识Lock接口,下面实现有ReentrantLock类,ReentrantReadWriteLock类,ConcurrentHashMap类,Segment类。我们再着重比较一下Java中的关键字,synchronized和volatile
synchronized
Java中的关键字、底层由Jvm虚拟机实现的同步机制,通过两条监听器指令:MONITORENTER(进入)、MONITOREXIT(退出)来实现同步效果(代码编译成字节码文件后可看到指令)
有三种使用方式:
- 修饰静态方法:锁住的是类,该类下创建的所有对象都被锁住
- 修饰实例方法:锁住的是当前对象,当前对象所属类创建的其他对象不受影响
- 修饰代码块(静态代码块、实例代码块):根据代码块所出区域来区别,如代码块在静态方法中,那锁的是整个类、如代码块在实例方法中,那锁住的是当前实例对象。
volatile
Java中的关键字、只可修饰变量,不可与final共存,valatile的主要作用是保证变量在内存中的可见性,有序性
- 可见性:valotile修饰的变量被修改后,对内存中是立即可见的。举个例子:有两个线程A、B。有一个valotile修饰的变量Y。当线程A对变量Y进行修改后,线程B能够立刻感知。感知到后会重新读取变量Y的最新值放到自己工作内存中进行操作。
- 有序性:我们都知道,Java代码是一行一行根据编写顺序去运行的。看着是顺序执行没错。但是实际底层JVM在执行字节码文件指令时,有些指令并不会依照代码顺序一样执行。因为Java编译器会在编译成字节码的时候为了提高性能会对你的代码进行指令优化,这个优化叫做指令重排。这个指令重排在单线程环境下不会有问题,因为不管你怎么重排指令,最后的结果都是期望的。但是如果在多线程环境下,就会有线程安全问题。所以valotile帮我们禁止了指令重排的优化,保证了编译器是严格按照代码编写顺序去编译指令
通过以上学习,我们对于计算机应用当中的多线程,同步和互斥,CAS操作,以及共享资源的原子性,线程间的排他性,唯一性有了更深的理解。谢谢支持~
参考:
线程同步和互斥的区别_weixin_43664019的博客-CSDN博客
面试不用怕,用最通俗易懂的语言,3分钟记住JAVA的16种锁
Java中Thread类的基本用法_java new thread_吃点橘子的博客-CSDN博客