多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。不同种类有不同的成本开销,不同的锁适用于不同的场景。
从资源已被锁定,线程是否阻塞可以分为 自旋锁(spinlock)和互斥锁(mutexlock);
从线程是否需要对资源加锁可以分为 悲观锁 和 乐观锁;
从多个线程并发访问资源,也就是 Synchronized 可以分为 无锁、偏向锁、 轻量级锁 和 重量级锁;
从锁的公平性进行区分,可以分为公平锁 和 非公平锁;
从根据锁是否重复获取可以分为可重入锁(自己获得锁以后,自己还可以进入锁之中) 和 不可重入锁;
从那个多个线程能否获取同一把锁分为共享锁和 排他锁;
互斥锁是在访问共享资源之前对其进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其它试图再次加锁的线程都会被阻塞,直到当前线程解锁。在这种方式下,只有一个线程能够访问被互斥锁保护的资源。如synchronized/Lock 这些方式都是互斥锁,不同线程不能同时进入 synchronized Lock 设定锁的范围
自旋锁是一种特殊的互斥锁,当资源被加锁后,其它线程想要再次加锁,此时该线程不会被阻塞睡眠而是陷入循环等待状态(CPU不能做其它事情),循环检查资源持有者是否已经释放了资源,这样做的好处是减少了线程从睡眠到唤醒的资源消耗,但会一直占用CPU的资源。
区别:互斥锁的起始开销要高于自旋锁,但是基本上是一劳永逸,临界区持锁时间的大小并不会对互斥锁的开销造成影响,而自旋锁是死循环检测,加锁全程消耗cpu,起始开销虽然低于互斥锁,但是随着持锁时间,加锁的开销是线性增长。
读写锁将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁。
ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。
读写锁也叫共享锁。其共享是在读数据的时候,可以让多个线程同时进行读操作的。在写的时候具有排他性,其他读或者写操作都要被阻塞。
1. 悲观锁
线程对一个共享变量进行访问,它就自动加锁,所以只能有一个线程访问它
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
缺点:只有一个线程对它操作时,没有必要加锁,造成了性能浪费
2.乐观锁
线程访问共享变量时不加锁,当执行完后,同步值到内存时,使用旧值和内存中的值进行判断,如果相同,那么写入,如果不相同,重新使用新值执行。乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
缺点:值相同的情况,可能被其他线程执行过;操作变量频繁时,重新执行次数多,造成性能浪费;完成比较后,写入前,被其他线程修改了值,导致不同步问题
1)synchronized 同步语句块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。
从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
2) synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo2.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo2.class。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
public class Singleton {
// 这里为什么需要加上volatile 后面会讲解
private volatile static Singleton uniqueInstance;
// 私有化构造方法
private Singleton() {
}
// 提供getInstance方法
public static Singleton getInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
其中uniqueInstance 变量采用 volatile 关键字修饰,分析如下:
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
1.为 uniqueInstance 分配内存空间
2.初始化 uniqueInstance
3.将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
可重入原理即加锁次数计数器。一个线程拿到锁之后,可以继续地持有锁,如果想再次进入由这把锁控制的方法,那么它可以直接进入。它的原理是利用加锁次数计数器来实现的。
1.每重入一次,计数器+1
每个对象自动含有一把锁,JVM负责跟踪对象被加锁的次数。
线程第一次给对象加锁的时候,计数器=0+1=1,每当这个相同的线程在此对象上再次获得锁时,计数器再+1。只有首先获取这把锁的线程,才能继续在这个对象上多次地获取这把锁
2.计数器-1
每当任务结束离开时,计数递减,当计数器减为0,锁被完全释放。
利用这个计数器可以得知这把锁是被当前多次持有,还是如果=0的话就是完全释放了。
不能。其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的 synchronized 修饰符要求执行方法时要获得对象的锁,如果已经进入A 方法说明对象锁已经被取走,那么试图进入 B 方法的线程就只能在等锁池(注意不是等待池哦)中等待对象的锁。
1)lock是一个接口,而synchronized是java的一个关键字。
2)synchronized在发生异常时会自动释放占有的锁,因此不会出现死锁;而lock发生异常时,不会主动释放占有的锁,必须手动来释放锁,可能引起死锁的发生。
synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:
(1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁
(2)ReentrantLock可以获取各种锁的信息
(3)ReentrantLock可以灵活地实现多路通知
另外,二者的锁机制其实也是不一样的。ReentrantLock底层调用的是Unsafe的park方法加锁,synchronized操作的应该是对象头中mark word,这点我不能确定。
1)synchronized保证内存可见性和操作的原子性
2)volatile只能保证内存可见性
3)volatile不需要加锁,比Synchronized更轻量级,并不会阻塞线程(volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。)
4)volatile标记的变量不会被编译器优化,而synchronized标记的变量可以被编译器优化(如编译器重排序的优化).
5)volatile是变量修饰符,仅能用于变量,而synchronized是一个方法或块的修饰符。
volatile本质是在告诉JVM当前变量在寄存器中的值是不确定的,使用前,需要先从主存中读取,因此可以实现可见性。而对n=n+1,n++等操作时,volatile关键字将失效,不能起到像synchronized一样的线程同步(原子性)的效果。
1. volatile 修饰变量
2. synchronized 修饰修改变量的方法
3. wait/notify
4. 轮询
监视器和锁在Java虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码。
在 java 虚拟机中, 每个对象( Object 和 class )通过某种逻辑关联监视器,每个监视器和一个对象引用相关联, 为了实现监视器的互斥功能, 每个对象都关联着一把锁.一旦方法或者代码块被 synchronized 修饰, 那么这个部分就放入了监视器的监视区域, 确保一次只能有一个线程执行该部分的代码, 线程在获取锁之前不允许执行该部分的代码。另外 java 还提供了显式监视器( Lock )和隐式监视器( synchronized )两种锁方案。
死锁 : 指多个线程在运行过程中因争夺资源而造成的一种僵局。比如有一个线程A,按照先锁a再获得锁b的的顺序获得锁,而在此同时又有另外一个线程B,按照先锁b再锁a的顺序获得锁。
死锁发生的必要条件
(1) 互斥,同一时刻只能有一个线程访问。
(2) 持有且等待,当线程持有资源A时,再去竞争资源B并不会释放资源A。
(3) 不可抢占,线程T1占有资源A,其他线程不能强制抢占。
(4) 循环等待,线程T1占有资源A,再去抢占资源B如果没有抢占到会一直等待下去。
想要破坏死锁那么上诉条件只要不满足一个即可,那么分析如下
(1) 互斥条件,不可破坏,如果破坏那么并发安全就不存在了。
(2) 持有且等待,可以破坏,可以一次性申请所有的资源。
(3) 不可抢占,当线程T1持有资源A再次获取资源B时,发现资源B被占用那么主动释放资源A。
(4) 循环等待,可以将资源排序,可以按照排序顺序的资源申请,这样就不会存在环形资源申请了。
活锁:是指线程1可以使用资源,但它很礼貌,让其他线程先使用资源,线程2也可以使用资源,但它很绅士,也让其他线程先使用资源。这样你让我,我让你,最后两个线程都无法使用资源。
就类似马路中间有条小桥,只能容纳一辆车经过,桥两头开来两辆车A和B,A比较礼貌,示意B先过,B也比较礼貌,示意A先过,结果两人一直谦让谁也过不去。
饥饿:是指如果线程T1占用了资源R,线程T2又请求封锁R,于是T2等待。T3也请求资源R,当T1释放了R上的封锁后,系统首先批准了T3的请求,T2仍然等待。然后T4又请求封锁R,当T3释放了R上的封锁之后,系统又批准了T4的请求…,T2可能永远等待。
类似有两条道A和B上都堵满了车辆,其中A道堵的时间最长,B相对相对堵的时间较短,这时,前面道路已疏通,交警按照最佳分配原则,示意B道上车辆先过,B道路上过了一辆又一辆,A道上排队时间最长的确没法通过,只能等B道上没有车辆通过的时候再等交警发指令让A道依次通过。
活锁和死锁类似,不同之处在于处于活锁的线程或进程的状态是不断改变的,活锁可以认为是一种特殊的饥饿。一个现实的活锁例子是两个人在狭小的走廊碰到,两个人都试着避让对方好让彼此通过,但是因为避让的方向都一样导致最后谁都不能通过走廊。简单的说就是,活锁和死锁的主要区别是前者进程的状态可以改变但是却不能继续执行。
饥饿与死锁有一定联系:二者都是由于竞争资源而引起的,但又有明显差别,主要表现在如下几个方面:
(1)从进程状态考虑,死锁进程都处于等待状态,忙式等待(处于运行或就绪状态)的进程并非处于等待状态,但却可能被饿死;
(2)死锁进程等待永远不会被释放的资源,饿死进程等待会被释放但却不会分配给自己的资源,表现为等待时限没有上界(排队等待或忙式等待);
(3)死锁一定发生了循环等待,而饿死则不然。这也表明通过资源分配图可以检测死锁存在与否,但却不能检测是否有进程饿死;
(4)死锁一定涉及多个进程,而饥饿或被饿死的进程可能只有一个。饥饿和饿死与资源分配策略有关,因而防止饥饿与饿死可从公平性考虑,确保所有进程不被忽视,如FCFS分配算法。