目录
常用锁策略
乐观锁与悲观锁
乐观锁:乐观的看待问题,认为线程之间的锁竞争不会太大,直接对数据修改,在结果返回的时候才会看是不是存在冲突(如果冲突,返回false,不会导致线程阻塞)
(小明想找老师问题,他认为老师在办公室,就直接过去(直接修改数据),到办公室了,如果老师在,就可以问问题,老师不在,就不能问问题(在结果返回的时候,才看看有无并发))
悲观锁:悲观的看待问题,认为线程之间的锁竞争太大,拿到数据之前先给数据加锁,结果得到之后再解锁
(小明想找老师问题,给老师发消息问老师在不在办公室(加锁),如果老师在,就去老师办公室问问题,老师不在,就不能问问题(得到结果后解锁))
乐观锁的问题是,如何检测线程冲突:引入版本号(version)解决
内存中存入数据的同时,存入版本号,线程读取内存信息时,其工作内存存放数据和版本号,每次执行操作之前,检查工作内存和内存中的版本号是否匹配,如果匹配,除了执行需要操作之外,额外将版本号+1,将数据写入内存;如果不匹配,直接结束,返回失败
读写锁
多个线程在读取数据时不会产生问题,但只要涉及到数据修改,就可能导致线程安全问题
有两个线程A和B,1、如果A和B都只是读,就可以不用加锁2、A是读,B是写,要加锁3、A是写,B是写,要加锁。如果对这三个情况,我们都使用同一种锁,那么对于1来说,是大可不必的,因此就出现了读写锁
读写锁就是对读和写操作区分对待,Java 标准库中提供了 ReentrantReadWriteLock 类,实现了读写锁
ReentrantReadWriteLock.ReadLock 表示一个读锁,提供了 lock/unlock 方法进行加锁释放锁
ReentrantReadWriteLock.WriteLock 表示一个写锁,提供了 lock/unlock 方法进行加锁释放锁
关于读写锁:
- 读与读之间不互斥
- 写与写之间互斥
- 读与写之间互斥
不存在读写锁时:
class Cache {
HashMap<Integer, Integer> map = new HashMap<>();
public void put(int id, int a) {
try {
System.out.println("写线程 " + id + "正在写入" + a);
Thread.sleep(100);//模拟写入的过程
map.put(id, a);
System.out.println("写线程 " + id + "写入完成");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void get(int id) {
System.out.println("读线程 " + id + "正在读取" + map.get(id));
}
}
public class demo2 {
public static void main(String[] args) {
Cache cache = new Cache();
//5个读写线程
Thread[] write = new Thread[5];
//写操作
for (int i = 0; i < 5; i++) {
int finalI = i;
write[i] = new Thread("写线程" + finalI) {
@Override
public void run() {
cache.put(finalI, finalI);
}
};
write[i].start();
}
//读操作
Thread[] reader = new Thread[5];
for (int i = 0; i < 5; i++) {
int finalI = i;
reader[i] = new Thread(() -> cache.get(finalI));
reader[i].start();
}
}
}
加了读写锁之后,实现了读写互斥
class MyCache {
HashMap<Integer, Integer> map = new HashMap<>();
//创建读写锁
ReentrantReadWriteLock wrlock =new ReentrantReadWriteLock();
public void put(int id, int a) {
wrlock.writeLock().lock();//创建写锁并且加锁
try {
System.out.println("写线程 " + id + "正在写入" + a);
Thread.sleep(100);
map.put(id, a);
System.out.println("写线程 " + id + "写入完成");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
wrlock.writeLock().unlock();//解锁
}
}
public void get(int id) {
wrlock.readLock().lock();//创建读锁并且加锁
System.out.println("读线程 " + id + "正在读取" + map.get(id));
wrlock.readLock().unlock();
}
}
读写锁适用于读操作频繁,写操作不频繁的情况
重量级锁与轻量级锁
CPU实现原子指令,在此基础上操作系统提供了mudex互斥锁(实现对临界资源的互斥访问),在操作系统的基础上,JVM提供了synchronzied和ReentrantLock锁
重量级锁:过度依赖于操作系统,多次涉及用户态和核心态的转化,容易引发线程调度
轻量级锁:尽量不依赖于操作系统,少次涉及用户态和核心态的转化,不容易引发线程调度
java的线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统内核态的支持,从而涉及到用户态和内核态的转换,而转换需要耗费时间,从而当锁竞争过多,就是重量级锁
公平锁和非公平锁
公平锁:根据申请加锁的线程的到来时间,按照顺序加锁
非公平锁:随机选择线程加锁
可重入锁和不可重入锁
可重入锁:同一个线程可以重复申请到同一个对象的锁
不可重入锁:同一个线程不可以重复申请到同一个对象的锁
自旋锁
线程在竞争锁失败的时候会进入阻塞状态,放弃 CPU,需要过很久才能被再次调度,但是如果,当前竞争锁失败但是没有过多久就会有线程释放锁,那么线程没必要放弃 CPU,这个时候就可以使用自旋锁来解决这种情况
CAS
互斥同步的问题就是进行进程阻塞和唤醒所带来的性能问题,因此这种同步也称之为阻塞同步,从处理方式来说,互斥同步属于悲观锁,我觉得代码会产生线程安全,所以我给代码加锁。
随着指令集的发展,我们有了另外的一种选择,基于冲突检测的乐观并发策略,就是先操作,操作之后在检查有没有并发,有并发了再采取措施;也就是基于乐观锁实现
我们需要保证操作和冲突检测这两个操作是原子的,就需要通过硬件来实现;硬件保证从一个语义上来说需要很多步骤的行为只通过一条处理机指令就可以完成,其中一个指令就是CAS
CAS在硬件的实现上,是一条原子指令,是 jdk 提供的一种乐观锁的实现,能够满足线程安全,以乐观锁的方式修改变量(多个线程竞争,只会有一个线程运行成功,其他线程只会返回false,不会导致阻塞)
CAS全称change and swap,一个CAS指令需要有三个操作数,分别是内存位置(V),旧的预期值(A),新的值(B)
CAS将上述操作,实现成为了原子操作
CAS的应用
实现原子类
java标准库提供了java.util.concurrent.atomic包,里面的类都是基于这种方式来实现的
其中有一个类AtomicInteger
public class demo9 {
public static void main(String[] args) {
AtomicInteger atomicInteger=new AtomicInteger();
System.out.println(atomicInteger.addAndGet(3));//将参数传入并返回参数
System.out.println(atomicInteger.getAndIncrement());//i++
System.out.println(atomicInteger.incrementAndGet());//++i
System.out.println(atomicInteger.decrementAndGet());;//--i
System.out.println(atomicInteger.getAndDecrement());//i--
}
}
AtomicInteger类的自增测试
class Text extends Thread {
AtomicInteger atomicInteger = new AtomicInteger(0);
int i = 0;
@Override
public void run() {
while (i < 10) {
System.out.println(atomicInteger.getAndIncrement());
i++;
}
}
}
public class demo10 {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
Text thread = new Text();
thread.start();
}
}
}
没有在加锁情况下,线程之间还没有产生线程不安全问题,就是因为AtomicInteger类是基于CAS实现的,操作具有原子性
实现自旋锁
在之前提到的synchronized锁中,如果一个线程获取锁失败了,就会进入阻塞状态,放弃CPU,直到下一次锁竞争成功
如果A线程持有了锁,B线程竞争加锁失败,B线程放弃了CPU,但是如果A线程不久之后就结束了,那么B线程放弃CPU就不应该了
自旋锁解决这个问题:如果获取锁失败,就一直循环等待加锁成功
自旋锁伪代码:while(getlock()==false){};
自旋锁的优缺点
- 一种轻量级的实现方法
- 不放弃CPU,不涉及到用户态和核心态的转化,不涉及到线程调度,一旦锁被释放,第一时间就可以获取到锁
- 如果锁被线程持有时间长,那么就会持续浪费CPU资源
public class demo11 {
Thread owner = null;
public void lock() {
//1通过ACS实现 如果this.owner==null,表示锁被占有 那么就循环等待
//2、如果不相等,将线程付给owner
while (!CAS(this.owner, null, Thread.currentThread()))
}
}
ABA问题
CAS中存在一个问题:如果A==C,就一定表示是线程安全的吗? 如果A被+50,后-50呢,因为这样导致的问题,称为ABA问题
解决方法:引入版本号
锁优化
jdk1.5到jdk1.6的一个重要改进就是实现了各种锁优化技术,如适应性自旋,锁消除,锁粗化,轻量级锁,偏向锁等,这些优化都是为了更好的解决线程之间的并发问题
自旋锁与自适应自旋
自旋锁就是一个线程竞争失败,会一直尝试获取锁
但是,一旦锁被一个进程占有的时间过长,另外一个进程自旋等待获取锁,就会消耗大量的CPU时间,因此自旋等待的时间一定是有限制的
JDK1.6中,引入了自适应的自旋锁,自适应就意味着自旋的等待时间不固定了,而是由前一个在同一个锁的自旋时间和锁拥有者的状态决定
有一个Thread线程,获取了锁m,这时线程A竞争锁m失败,陷入自旋等待,Thread线程释放了锁,A获取锁成功,A等待耗时了x秒
这时,如果有一个线程B申请获取m锁,这时,jvm就认为A获取成功了,并且持有锁的线程正在运行中,jvm就会认为A成功了,B也可能成功,让线程B陷入自旋等待,并规定持续时间可以比A等待的时间长;反之,如果A失败了,jvm就认为B也可能失败,就可能会放弃B的自旋
锁消除
锁消除指虚拟机在运行时,会对一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除
public static String concat(String a,String b){ return a+b;}
在执行这个方法时,我们知道String是一个不可变的类,在运行时,可能转变为StringBuilder或者StringBuffer对象的连续append操作,也就是如下所示:
public static String concat(String a,String b) {
StringBuffer str = new StringBuffer();
str.append(a);
str.append(b);
return str.toString();
}
但是, StringBuffer类是线程安全的,带有加锁操作,如果我们在单线程模式下用到了StringBuffer,那么就会产生不必要的锁操作,这时,jvm的锁擦除机制就会去掉锁,以提高运行效率
锁粗化
原则上,我们在编写代码的时候,会尽量的让锁的范围小,只在共享数据处加锁
这个原则大部分情况下都是正确的,但是一个代码中,如果反复的针对一个对象加解锁,就会降低运行效率
public static String concat(String a,String b) {
StringBuffer str = new StringBuffer();
str.append(a);
str.append(b);
return str.toString();
}
例如,以上这个代码,两次append(),就执行两次加解锁,就会导致不必要的性能消耗,这时,jvm的锁粗化就会将锁的范围扩大至 第一个append()之前,第二个append()之后,从而减少加锁次数
轻量级锁
轻量级锁本意是在没有多线程竞争的前提下,减少传统的”重量级“锁使用操作系统的互斥量产生的性能消耗
要理解轻量级锁和后面提到的偏向锁,就要说到HotSpot虚拟机的对象(对象头部分)的内存布局
HotSpot虚拟机的对象头分为两部分信息:
- 一部分用于存储对象自身运行时的数据,这部分的数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称为”mark word”,它是实现轻量级锁和偏向锁的关键
- 另外一部分是类型指针,这个指针指向方法区的对象类型,java虚拟机通过这个部分指示对象是哪个类的实例
轻量级锁第一次加锁,使用CAS操作修改了对象头中锁的标志位,表示此对象处于轻量级锁定状态,加锁成功;如果CAS操作失败,先检查是否是当前线程已经拥有了锁,如果是,那就继续运行(可重入),如果不拥有,表示存在锁竞争,这时,轻量级锁就会膨胀为重量级锁
偏向锁
jdk1.6引入的优化策略之一,目的就是消除数据在无竞争情况下的同步原语,就是当轻量级锁在无竞争的情况下消去整个同步,甚至不进行CAS操作
当锁对象第一次被线程获取,虚拟机修改对象头的所标志位是01,就是偏向模式,持有偏向锁的线程以后每次进入这个锁,虚拟机不会进行任何加锁过程;当产生了锁竞争,偏向模式就解除了
偏向锁不是真的加锁,只相当于做了一个标记,表示当前线程想要访问这个代码块;没有产生竞争的情况下,这个线程是完全是不需要加锁的,因为一个线程本身就是安全的;但是一旦发生了竞争,就会进入轻量级锁
偏向锁适用于有同步,但是无竞争的情况(就可以减少不必要的加锁)
偏向锁的本质:延迟加锁,能不加就不加
Synchronized原理
加锁的工作过程
ReentrantLoak锁
synchronized同步代码块和同步方法可以使用一种封闭的锁机制,使用简单,但是却无法中断一个正在等候获得锁的线程,同时,如果这个线程申请锁失败之后,会下次继续申请锁,一直持续到获取到锁为止
jdk5开始,增加了一个功能更加强大的Lock锁,Lock锁和synchronized在功能上基本相同,但是Lock锁可以让某一个线程在持续获得同步锁失败后返回,不在继续等待
Lock是一个接口,需要使用ReentrantLoak类实现
class LockThread implements Runnable {
Lock lock = new ReentrantLock();//ReentrantLock类实现Lock接口
private int tickets = 100;
@Override
public void run() {
while (true) {
lock.lock();//加锁
if (tickets > 0) {
try {
Thread.sleep(100);
System.out.println(Thread.currentThread().getName() + "卖出了第" + tickets-- + "张票");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();//解锁
}
}
}
}
}
public class demo1 {
public static void main(String[] args) {
LockThread lockThread = new LockThread();
new Thread(lockThread, "窗口一").start();
new Thread(lockThread, "窗口二").start();
new Thread(lockThread, "窗口三").start();
}
}
trylock()方法
public class demo7 {
private static final Logger logger = Logger.getAnonymousLogger();//创建记录器 可以用于记录系统消息
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread thread = new Thread("thread") {
@Override
public void run() {
logger.info("thread线程尝试获取锁");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (lock.tryLock()) {//获取到了锁
logger.info("thread线程获取到了锁");
} else {
logger.info("thread线程没有获取到锁");
return;
}
}
};
logger.info("主线程尝试获取锁");
thread.start();
lock.lock();
Thread.sleep(100);
logger.info("主线程获取到了锁");
lock.unlock();
}
使用trylock()方法解决哲学家就餐问题
class Chopstick implements Runnable {
ReentrantLock left = new ReentrantLock();
ReentrantLock right = new ReentrantLock();
@Override
public void run() {
try {
if (left.tryLock(2, TimeUnit.SECONDS)) {//两秒内左筷子获取成功
if (right.tryLock(2, TimeUnit.SECONDS)) {//右筷子获取成功
System.out.println(Thread.currentThread().getName() + "拿到了左右筷子,可以吃饭了");
right.unlock();
left.unlock();
} else {//左 拿到了 右没拿到
left.unlock();
return;
}
} else {//左边筷子没拿到
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class demo8 {
public static void main(String[] args) {
Chopstick thread=new Chopstick();
new Thread(thread,"1号哲学界").start();
new Thread(thread,"2号哲学界").start();
new Thread(thread,"3号哲学界").start();
new Thread(thread,"4号哲学界").start();
new Thread(thread,"5号哲学界").start();
}
}
ReentrantLock的三个特性
支持公平锁和非公平锁
在ReentrantLock中,有个内部类Sync继承了AbstractQueuedSynchronizer类(AQS)
Sync在ReentrantLock类中有两个子类FairSync和NonFairSync,也就是公平锁与非公平锁。
公平锁:所有申请加锁的线程存储在队列中,如果前面有线程在申请加锁,新加入的线程就要存储在它之后(先来的线程先被服务,即为公平)
非公平锁:随机抽取线程加锁
ReentrantLock是默认非公平的,可以在构造方法传入参数设置是否公平
如何支持非公平锁
第一步:判断是否需要加锁
第二步:判断需要加锁,调用acquire方法获取锁,如果获取锁失败了,将线程加入等待队列,并将线程挂起
尝试获取锁 ,观察代码,发现是抢占式获取锁的
获取锁失败,将线程加入等待队列
将线程挂起
总结:非公平锁中,可以随机的申请加锁,加锁成功(不用进入AQS队列),要么,将线程加入等待队列后被挂起,等待下次唤醒后继续循环尝试获取锁
如何支持公平锁
总结:公平锁中,要按照申请加锁的顺序加锁(AQS队列是先进先出),加锁成功(不用进入AQS队列),要么,将线程加入等待队列后被挂起,等待下次唤醒后继续循环尝试获取锁
等待可中断
等待可中断指当前持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他的事情
等待不可中断
调用ReentrantLock的构造方法,等待不可以中断
说明:线程没有获取到锁,会被加入等待队列,后执行acquireQueued()方法挂起线程。acquireQueued()方法中,当线程不是首元结点时,会执行parkAndCheckInterrupt()进行等待,如果线程被唤醒,线程继续执行,设置interrupted = true,再次进行自旋,如果没有成功获取到锁,会再次执行parkAndCheckInterrupt()方法等待,一直持续循环,直到获取到锁,执行if语句,此时interrupted=true,返回true,循环终止
结果true返回到acqure()方法中,这时if()条件进入,执行selfInterupt(),再次产生中断
等待可中断
通过lockInterruptibly()方法获取锁
线程没有获取到锁,会尝试获取锁,获取不到,会执行parkAndCheckInterrupt()进行等待,如果线程被唤醒,这时候直接抛出异常, 而不会再次进入 for ( ;; )
public class demo4 {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Thread thread = new Thread() {
@Override
public void run() {
try {
System.out.println("therad线程申请获取锁");
lock.lockInterruptibly();//可以产生中断
lock.unlock();
} catch (InterruptedException e) {
System.out.println("therad线程获取到锁");
e.printStackTrace();
}
}
};
lock.lock();//加锁
System.out.println("主线程加锁");
thread.start();
thread.interrupt();
}
}
总结:ReentrantLock的 lock 方法也是不可打断的,而 lockInterruptibly() 方法是可打断的。
观察lockInterruptibly() 方法的源码发现,它其实也是通过检查打断标记抛出InterruptedException异常,来实现可打断的功能
锁可以绑定多个条件
一个ReentrantLock可以同时绑定多个Condiction对象,而在synchronized中,锁对象的wait()和notify()如果有多对匹配,就要有多把不同的锁,但是ReentrantLock只需要多次调用newCondition()即可
每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject。ConditionObject是AQS的内部类,同样是基于AQS中的Node类维护等待队列
类似于Thread的wait()和signal(),ReentrantLock使用await()和signal()实现阻塞与唤醒 在线程安全下使用
public class demo5 {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Condition a = lock.newCondition();
Condition b = lock.newCondition();
Thread thread = new Thread() {
@Override
public void run() {
lock.lock();
try {
System.out.println("线程a正在运行");
a.await();
b.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
};
thread.start();
Thread thread1 = new Thread() {
@Override
public void run() {
lock.lock();
try {
System.out.println("线程b正在运行");
a.signal();
b.await();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
};
thread1.start();
}
}
Synchronzied和lock的区别
Synchronzied的特性
1、是乐观锁,也是悲观锁(根据竞争情况决定,是自适应的)
2、是互斥锁,不是读写锁
3、是轻量级锁,也是重量级锁(根据竞争情况决定,是自适应的)
4、是非公平锁
5、可重入锁
6、等待不可中断:Synchronzied的是靠Thread.interrupt()方法实现中断的,其内部并没有抛出异常,故而等待不可中断
ReentrantLock的特性
1、可重入锁
2、等待可中断:当一个线程一直等不到锁释放,可以放弃竞争
ReentrantLock的 lock 方法也是不可打断的,而 lockInterruptibly() 方法是可打断的。
3、支持公平锁和非公平锁,默认非公平的
ReentrantLock lock = new ReentrantLock(true);//通过构造方法,改为公平的
4、支持多个条件变量(Condition),即等待不同条件的线程可以进入不同的等待队列
Synchronzied和ReentrantLock的区别
1、Synchronzied是关键字,是在jvm内部实现的,ReentrantLock是类,是在jvm外部实现的
2、Synchronzied等待不可中断,ReentrantLock等待可中断
3、Synchronzied不需要手动加锁解锁,ReentrantLock需要手动加锁解锁
4、Synchronzied在申请锁失败时,会陷入阻塞等待,参与下一次的锁竞争,直到获取到锁为止;
ReentrantLock的trylock()方法,可以申请获取一次锁,获取失败返回false,不会死等加锁
信号量
操作系统的互斥机制 ,用于表示可用的资源数 p操作消耗 v操作释放
public class demo13 {
public static void main(String[] args) {
Semaphore semaphore=new Semaphore(4);//表示有四个可用资源
Runnable runnable=new Runnable() {
@Override
public void run() {
try {
System.out.println("申请获取资源");
semaphore.acquire();
System.out.println("获取到资源了");
System.out.println("释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i <5 ; i++) {
new Thread(runnable).start();
}
}
}
CountDownLatch类
例如:800米体测,一队伍10个人全部跑玩,才视作比赛结束
public class demo14 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);//10个人
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "跑完了");
countDownLatch.countDown();//计数-1
}
};
for (int i = 0; i < 10; i++) {
new Thread(runnable).start();
}
countDownLatch.await();//等待所有线程全部结束 计数=0
System.out.println("800米体测结束");
}
}
多线程下使用Hash表
1、直接使用HashTable
- 给方法加锁,对象调用每个方法都会加锁,相当于直接给整个对象加了锁,产生锁冲突
- 一旦涉及到扩容,这个线程就要完成大量的数据拷贝
2、使用ConcurrentHashMap
1、get()方法不加锁,但是val属性是volatitle的,保证可以从内存读取数据
2、put()方法加synchronized加锁,但不是对整个对象加锁,是对每一个链表加锁
3、大量利用CAS操作
4、优化了扩容方式