开始正文之前,先对涉及到的一些知识点进行回顾:
一、技术点
1.线程与进程
运行一个程序最少需要一个进程,而一个进程最少需要一个线程。关系是线程–>进程–>程序的大致组成结构。所以线程是程序执行流的最小单位,而进程是系统进行资源分配和调度的一个独立单位。以下我们所讨论的都是建立在线程基础之上。
Thread的几个重要方法:
start()方法,调用该方法开始执行该线程;
stop()方法,调用该方法强制结束该线程执行;
join()方法,调用该方法等待该线程结束;
sleep()方法,调用该方法该线程进入等待;
run()方法,调用该方法直接执行线程的run()方法,线程调用start()方法时也会运行run()方法,区别就是直接调用run()方法不会开启新的线程去执行任务,通过start()方法才会开启新线程去执行run()方法中的任务。
看到这里,可能有些人就会问,那wait()和notify()呢?要注意,其实wait()与notify()方法是Object的方法,不是Thread的方法!同时,wait()与notify()会配合使用,分别表示线程挂起和线程恢复。
这里还有一个很常见的问题,顺带提一下:wait()与sleep()的区别,简单来说wait()会释放对象锁而sleep()不会释放对象锁。这些问题有很多的资料,不再赘述。
如果对线程的状态或生命周期不是很熟悉的同学,可以参考以下文章:
https://blog.youkuaiyun.com/weixin_46371175/article/details/109251527
2.java中的锁
在并发场景中,会涉及到各种各样的锁,如公平锁,乐观锁,共享锁、分段锁等等,大体分类如下,如果想具体了解各种锁的优缺点及实现方式,可参考以下文章:
https://blog.youkuaiyun.com/weixin_46371175/article/details/109171387
二、synchronized与Lock的区别
详情对比如下:
三、Lock详解
Lock接口的部分源码如下:
public interface Lock {
/**
* Acquires the lock.
*/
void lock();
/**
* Acquires the lock unless the current thread is
* {@linkplain Thread#interrupt interrupted}.
*/
void lockInterruptibly() throws InterruptedException;
/**
* Acquires the lock only if it is free at the time of invocation.
*/
boolean tryLock();
/**
* Acquires the lock if it is free within the given waiting time and the
* current thread has not been {@linkplain Thread#interrupt interrupted}.
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
/**
* Releases the lock.
*/
void unlock();
}
从Lock接口中我们可以看到主要有5个方法,这些方法的功能从注释中可以看出:
lock():获取锁,如果锁被暂用则一直等待
unlock():释放锁
tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false
tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,保证等待参数时间
lockInterruptibly():用该锁的获得方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事。
下面是Lock一般使用的例子,注意ReentrantLock是Lock接口的实现。
lock():
package com.brickworkers;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockTest {
private Lock lock = new ReentrantLock();
//需要参与同步的方法
private void method(Thread thread){
lock.lock();
try {
System.out.println("线程名"+thread.getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println("线程名"+thread.getName() + "释放了锁");
lock.unlock();
}
}
public static void main(String[] args) {
LockTest lockTest = new LockTest();
//线程1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t2");
t1.start();
t2.start();
}
}
//执行情况:线程名t1获得了锁
// 线程名t1释放了锁
// 线程名t2获得了锁
// 线程名t2释放了锁
tryLock():
package com.brickworkers;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockTest {
private Lock lock = new ReentrantLock();
//需要参与同步的方法
private void method(Thread thread){
/* lock.lock();
try {
System.out.println("线程名"+thread.getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println("线程名"+thread.getName() + "释放了锁");
lock.unlock();
}*/
if(lock.tryLock()){
try {
System.out.println("线程名"+thread.getName() + "获得了锁");
}catch(Exception e){
e.printStackTrace();
} finally {
System.out.println("线程名"+thread.getName() + "释放了锁");
lock.unlock();
}
}else{
System.out.println("我是"+Thread.currentThread().getName()+"有人占着锁,我就不要啦");
}
}
public static void main(String[] args) {
LockTest lockTest = new LockTest();
//线程1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
lockTest.method(Thread.currentThread());
}
}, "t2");
t1.start();
t2.start();
}
}
//执行结果: 线程名t2获得了锁
// 我是t1有人占着锁,我就不要啦
// 线程名t2释放了锁
相信大家应该都会使用Lock了,关于tryLock(long time, TimeUnit unit)和lockInterruptibly()不再赘述。前者主要存在一个等待时间,在测试代码中写入一个等待时间,后者主要是等待中断,会抛出一个中断异常,常用度不高,喜欢探究可以自己深入研究。
前面提到“公平锁”,在这里可以提一下ReentrantLock对于公平锁的定义,在源码中有这么两段:
/**
* Sync object for non-fair locks
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
/**
* Sync object for fair locks
*/
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
从以上源码可以看出在Lock中可以自己控制锁是否公平,而且,默认的是非公平锁,以下是ReentrantLock的构造函数:
public ReentrantLock() {
sync = new NonfairSync();//默认非公平锁
}
四、synchronized详解
synchronized:我们知道java是用字节码指令来控制程序(这里不包括热点代码编译成机器码)。在字节指令中,存在有synchronized所包含的代码块,那么会形成2段流程的执行。
public class SyncDemo {
public void sync(){
synchronized (SyncDemo.class){
System.out.println(" ");
}
}
}
我们点击查看SyncDemo.java的源码SyncDemo.class,可以看到如下:
如上就是这段代码段字节码指令,没你想的那么难吧。言归正传,我们可以清晰段看到,其实synchronized映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当一条线程进行执行的遇到monitorenter指令的时候,它会去尝试获得锁,如果获得锁那么锁计数+1(为什么会加一呢,因为它是一个可重入锁,所以需要用这个锁计数判断锁的情况),如果没有获得锁,那么阻塞。当它遇到monitorexit的时候,锁计数器-1,当计数器为0,那么就释放锁。
那么有的朋友看到这里就疑惑了,那图上有2个monitorexit?因为synchronized锁释放有两种机制,一种就是执行完释放;另外一种就是发生异常,虚拟机释放。图中第二个monitorexit就是发生异常时执行的流程,这就是我开头说的“会有2个流程存在“。而且,从图中我们也可以看到在第13行,有一个goto指令,也就是说如果正常运行结束会跳转到19行执行。
接下来我们再聊一聊Lock。
Lock
Lock实现和synchronized不一样,后者是一种悲观锁。而Lock呢底层其实是CAS乐观锁的体现。具体底层怎么实现,这里不在细述。如果面试问起,就说底层主要靠volatile和CAS操作实现的。
现在,我要说的是:尽可能去使用synchronized而不要去使用LOCK!
什么概念呢?我和大家打个比方:你叫jdk,你生了一个孩子叫synchronized,后来呢,你领养了一个孩子叫LOCK。起初,LOCK刚来到新家的时候,它很乖,很懂事,各个方面都表现的比synchronized好。你很开心,但是你内心深处又有一点淡淡的忧伤,你不希望你自己亲生的孩子竟然还不如一个领养的孩子乖巧。这个时候,你对亲生的孩子教育更加深刻了,你想证明,你的亲生孩子synchronized并不会比领养的孩子LOCK差。(只是打个比方)那如何教育呢?在jdk1.6~jdk1.7的时候,给他优化了,具体优化在哪里呢:
1、线程自旋和适应性自旋
我们知道,java线程其实是映射在内核之上的,线程的挂起和恢复会极大的影响开销。并且jdk官方人员发现,很多线程在等待锁的时候,在很短的一段时间就获得了锁,所以它们在线程等待的时候,并不需要把线程挂起,而是让他无目的的循环,一般设置10次。这样就避免了线程切换的开销,极大的提升了性能。
而适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次一下。他可以根据它前面线程的自旋情况,从而调整它的自旋,甚至是不经过自旋而直接挂起。
2、锁消除
什么叫锁消除呢?就是把不必要的同步在编译阶段进行移除。那么有的小伙伴又迷糊了,我自己写的代码我会不知道这里要不要加锁?我加了锁就是表示这边会有同步呀?并不是这样,这里所说的锁消除并不一定指代是你写的代码的锁消除,我打一个比方:
在jdk1.5以前,我们的String字符串拼接操作其实底层是StringBuffer来实现的(这个大家可以用我前面介绍的方法,写一个简单的demo,然后查看class文件中的字节码指令就清楚了),而在jdk1.5之后,那么是用StringBuilder来拼接的。我们考虑前面的情况,比如如下代码:
String str1="qwe";
String str2="asd";
String str3=str1+str2;
底层实现会变成这样:
StringBuffer sb = new StringBuffer();
sb.append("qwe");
sb.append("asd");
我们知道,StringBuffer是一个线程安全的类,也就是说两个append方法都会同步,通过指针逃逸分析(就是变量不会外泄),我们发现在这段代码并不存在线程安全问题,这个时候就会把这个同步锁消除。
关于String、StringBuffer和StringBuilder可以参考以下文章:
https://blog.youkuaiyun.com/weixin_46371175/article/details/109012608
3、锁粗化
在用synchronized的时候,我们都讲究为了避免大开销,尽量同步代码块要小。那么为什么还要加粗呢?
我们继续以上面的字符串拼接为例,我们知道在这一段代码中,每一个append都需要同步一次,那么我可以把锁粗化到第一个append和最后一个append(这里不要去纠结前面的锁消除,我只是打个比方)
4、轻量级锁
轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。
4.1 轻量级锁的加锁过程
(1)在代码进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
(4)如果这个更新动作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
(5)如果这个更新操作失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
4.2 轻量级锁的解锁过程
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果替换成功,整个同步过程完成。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
5、偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
五、总结
- lock是一个接口,而synchronized是java的一个关键字
- synchronized异常会释放锁,lock异常不会释放,所以一般try catch包起来,finally中写入unlock,避免死锁。
- Lock可以提高多个线程进行读操作的效率
- synchronized关键字,可以放代码块,实例方法,静态方法,类上
- lock一般使用ReentrantLock类做为锁,配合lock()和unlock()方法。在finally块中写unlock()以防死锁。
- jdk1.6之前synchronized低效。jdk1.6之后synchronized高效。