您的位置:首页 > 编程语言 > Java开发

Java中的锁——Lock和synchronized

2019-04-14 22:36 1106 查看

上一篇Java中的队列同步器AQS

一、Lock接口

1、Lock接口和synchronized内置锁

a)synchronized:Java提供的内置锁机制,Java中的每个对象都可以用作一个实现同步的锁(内置锁或者监视器Monitor),线程在进入同步代码块之前需要或者这把锁,在退出同步代码块会释放锁。而synchronized这种内置锁实际上是互斥的,即没把锁最多只能由一个线程持有。

b)Lock接口:Lock接口提供了与synchronized相似的同步功能,和synchronized(隐式的获取和释放锁,主要体现在线程进入同步代码块之前需要获取锁退出同步代码块需要释放锁)不同的是,Lock在使用的时候是显示的获取和释放锁。虽然Lock接口缺少了synchronized隐式获取释放锁的便捷性,但是对于锁的操作具有更强的可操作性、可控制性以及提供可中断操作和超时获取锁等机制。

2、lock接口使用的一般形式

Lock lock = new ReentrantLock(); //这里可以是自己实现Lock接口的实现类,也可以是jdk提供的同步组件
lock.lock();//一般不将锁的获取放在try语句块中,因为如果发生异常,在抛出异常的同时,也会导致锁的无故释放
try {
}finally {
lock.unlock(); //放在finally代码块中,保证锁一定会被释放
}

3、Lock接口的方法

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TestCR {
Lock lock = new ReentrantLock();

void m1(){
try{
lock.lock(); // 加锁
for(int i = 0; i < 4; i++){
TimeUnit.SECONDS.sleep(1);
System.out.println("m1() method " + i);
}
m2(); //在释放锁之前,调用m2方法
}catch(InterruptedException e){
e.printStackTrace();
}finally{
lock.unlock(); // 解锁
}
}

void m2(){
lock.lock();
System.out.println("m2() method");
lock.unlock();
}

public static void main(String[] args) {
final TestCR t = new TestCR();
new Thread(new Runnable() {
@Override
public void run() {
t.m1();
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
t.m2();
}
}).start();
}
}
ReentrantLock的可重入测试

b)下面分析ReentrantLock的部分源码来学习这个同步组件(默认的非公平锁实现)

①首先可以知道ReentrantLock实现Lock接口public class ReentrantLock implements Lock 

abstract static class Sync extends AbstractQueuedSynchronizer {
/**
* 创建非公平锁的方法
*/
abstract void lock();

/**
* 执行非公平的tryLock。 tryAcquire实现于
* 子类,但两者都需要tryf方法的非公平尝试。
*/
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();//获取当前线程
int c = getState(); //获取当前同步状态的值
if (c == 0) { //如果当前的同步状态还没有被任何线程获取
if (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); //更新state的值
return true;
}
return false;
}

/**
* 释放同步状态的处理逻辑
*/
protected final boolean tryRelease(int releases) {
int c = getState() - releases; //对同一线程而言,就是减去相应的获取次数
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false; //返回值
if (c == 0) { //只有该线程将获取的次数全部释放之后,才会返回true,并且将当前同步状态的持有者设置为null
free = true;
setExclusiveOwnerThread(null);
}
setState(c); //更新state
return free;
}

/**
* 判断当前同步状态的持有者线程
*/
protected final boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}

final ConditionObject newCondition() {
return new ConditionObject();
}

/**
* 返回当前持有者线程
*/
final Thread getOwner() {
return getState() == 0 ? null : getExclusiveOwnerThread();
}

/**
* 返回持有同步状态的线程获取次数
*/
final int getHoldCount() {
return isHeldExclusively() ? getState() : 0;
}

/**
* 判断当前是否有线程获取到同步状态(根据state值进行判断)
*/
final boolean isLocked() {
return getState() != 0;
}

private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
setState(0); // reset to unlocked state
}
}

②通过上面的非公平锁的实现源码可以看到,ReentrantLock实现可重入的逻辑大概上是这样的:

  获取逻辑:首先通过nonfairTryAcquire方法增加了对于同一线程再次获取同步状态的逻辑处理(通过判断当前线程是否为已经同步状态的持有者,来决定是否能够再次获取同步状态,如果当前线程是已经获取到同步状态的那个线程,那么就能够获取成功,并且同时以CAS的方式修改state的值)

  释放逻辑:对于成功获取到同步状态的线程,在释放锁的时候,通过tryRelease方法的实现可以看出,如果该锁被线程获取到了n次,那么前(n-1)次释放的操作都会返回false,只有将同步状态完全释放才会返回true。最终获取到同步状态的线程在完全释放掉之后,state值为0并且持有锁的线程为null。

c)关于ReentrantLock的公平和非公平实现

①非公平锁

  公平和非公平是针对于获取锁而言的,对于公平锁而言获取锁应该遵循FIFO原则,上面我们通过源码分析了非公平锁的实现(对于非公平锁而言,tryAcquire方法直接使用的是ReentrantLock静态内部类Sync的nofairTryAcquire方法)

//非公平锁实现
static final class NonfairSync extends Sync {

/**
* 以CAS方式原子的更新state的值
*/
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

/**
* 非公平锁的实现是直接调用Sync的nonfairTryAcquire方法
*/
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}

②公平锁实现

  公平锁的实现和非公平实现的主要区别就是tryAcquire方法的实现

static final class FairSync extends Sync {

final void lock() {
acquire(1); //调用AQS的模板方法实现锁的获取
}

/**
* 公平锁的处理逻辑
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread(); //获取当前线程
int c = getState(); //获取当前同步状态的值
if (c == 0) { //当前同步状态没有被任何线程获取的时候
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) { //这个点的主要处理逻辑就是:hasQueuedPredecessors判断当前线程所在的结点是否含有前驱结点,
                                  如果返回值为true表示有前驱结点,那么当前线程需要等待前驱结点中的线程获取并释放锁之后才能获取锁,保证了FIFO 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; } } //hasQueuedPredecessors的处理逻辑 public final boolean hasQueuedPredecessors() { // 简单而言,就是判断当前线程是否有前驱结点 // 当前结点含有前驱结点时候返回true;当前结点为头结点挥着队列为空的时候返回false Node t = tail; // Read fields in reverse initialization order Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }

d)公平锁和非公平锁的测试

  ①测试目的

  验证上面通过源码分析的,非公平锁在获取锁的时候会首先进行抢锁,在获取锁失败后才会将当前线程加入同步队列队尾中,而公平锁则是符合请求的绝对顺序,也就是会按照先来后到FIFO。在下面的代码中我们使用一个静态内部类继承了ReentrantLock并重写等待队列的方法,作为测试的ReentrantLock。然后创建5个线程,每个线程连续两次去获取锁,分别测试公平锁和非公平锁的测试结果

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import org.junit.Test;

public class TestReentrantLock {
/**
* ReentrantLock的构造方法
* public ReentrantLock(boolean fair) {sync = fair ? new FairSync() : new NonfairSync();}
*/
private Lock fairLock = new ReentrantLock2(true);
private Lock unFairLock = new ReentrantLock2(false);

@Test
public void testFair() throws InterruptedException {
testLock(fairLock); //测试公平锁
}

@Test
public void testUnFair() throws InterruptedException {
testLock(unFairLock); //测试非公平锁
}

private void testLock(Lock lock) throws InterruptedException {
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Job(lock)) {
public String toString() {
return getName();
}
};
thread.setName(i+"");
thread.start();
}
Thread.sleep(12000);
}

private static class Job extends Thread {
private Lock lock;
public Job(Lock lock) {
this.lock = lock;
}
@Override
public void run() {
//两次打印当前线程和等待队列中的Threads
for (int i = 0; i < 2; i++) {
lock.lock(); //获取锁
try {
Thread.sleep(1000);
System.out.println("当前线程=>" + Thread.currentThread().getName() + " " +
"等待队列中的线程=>" + ((ReentrantLock2)lock).getQueuedThreads());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); //释放锁
}
}
}

}

private static class ReentrantLock2 extends ReentrantLock {
public ReentrantLock2(boolean fair) {
super(fair);
}
public Collection<Thread> getQueuedThreads() { //逆序打印等待队列中的线程
List<Thread> list = new ArrayList<Thread>(super.getQueuedThreads());
Collections.reverse(list);
return list;
}
}

}

  ②测试非公平锁

  由上面的测试结果简单的得到关于非公平锁的一个结论:通过nofairTryAcquire方法可以得到这样一个前提,当一个线程请求一个锁时,判断获取成功的条件就是这个线程获取到同步状态就可以,那么某个刚刚释放锁的线程再次获取到同步状态的几率就会更大一些(当然实验中也出现并非连续两次获取这把锁的情况,比如下面的测试结果)

  ③测试公平锁

  通过分析下面的测试结果,对于使用公平锁而言,即便是同一个线程连续两次获取锁释放锁,在第一次释放锁之后还是会被放在队尾并从队列头部拿出线程进行执行。并没有出现像非公平锁那样连续两次获取锁的那种情况

   ④由上面的测试可以看出:非公平锁可能导致在队尾的线程饥饿,但是又因为同一个线程在释放锁的时候有更大的概率再次获取到这把锁,那么这样的话线程的切换次数就会更少(这带来的就是更大的吞吐量和开销的减小)。而虽然公平锁的获取严格按照FIFO的规则,但是线程切换的次数就会更多。

三、Synchronized

1、Synchronized作用对象

①对于普通方法,锁的是当前实例对象

②对于静态同步方法,锁的是类的Class对象

③对于同步代码块,锁的是Synchronized括号中的对象

如下所示的三种情况

package cn.source.sync;

public class TestSync01 {
private static int count = 0;
private Object object = new Object();

public void testSyn1() {
//同步代码块(这里面是锁临界资源,即括号中的对象)
synchronized (object) {
System.out.println(Thread.currentThread().getName()
+" count =" + count++);
}
}

public void testSyn2() {
//锁当前对象(相当于普通同步方法)
synchronized (this) {
System.out.println(Thread.currentThread().getName()
+" count =" + count++);
}
}

//普通同步方法:锁当前对象
public synchronized void testSyn3() {
System.out.println(Thread.currentThread().getName()
+" count =" + count++);
}

//静态同步方法,锁的是当前类型的类对象(即TestSync01.class)
public static synchronized void testSyn4() {
System.out.println(Thread.currentThread().getName()
+" count =" + count++);
}

//下面的这种方式也是锁当前类型的类对象
public static void testSyn5() {
synchronized (TestSync01.class) {
System.out.println(Thread.currentThread().getName()
+" count =" + count ++);
}
}
}

2、synchronized的实现原理

  ①Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。同步代码块是使用monitorenter和monitorexit来实现的,同步方法 并不是由 monitor enter 和 monitor exit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。monitorenter指令是在编译后插入同步代码块的起始位置,而monitorexit指令是在方法结束处和异常处,每个对象都有一个monitor与之关联,当一个monitor被持有后它就会处于锁定状态。

  ②synchronized用的锁是存在Java对象头(非数组类型包括Mark Word、类型指针,数组类型多了数组长度)里面的,对象头中的Mark Word存储对象的hashCode,分代年龄和锁标记位,类型指针指向对象的元数据信息,JVM通过这个指针确定该对象是那个类的实例等信息。

  ③当在对象上加锁的时候,数据是记录在对象头中,对象头中的Mark Word里存储的数据会随着锁标志位的变化而变化(无锁、轻量级锁00、重量级锁10、偏向锁01)。当执行synchronized的同步方法或者同步代码块时候会在对象头中记录锁标记,锁标记指向的是monitor对象(也称为管程或者监视器锁)的起始地址。由于每个对象都有一个monitor与之关联,monitor和与关联的对象一起创建(当线程试图获取锁的时候)或销毁,当monitor被某个线程持有之后,就处于锁定状态。

  ④Hotspot虚拟机中的实现,通过ObjectMonitor来实现的

  如图所示,ObjectMonitor中有两个队列(EntryList、WaitSet)以及锁持有者Owner标记,其中WaitSet是哪些调用wait方法之后被阻塞等待的线程队列,EntryList是ContentionList中能有资格获取锁的线程队列。当多个线程并发访问同一个同步代码时候,首先会进入EntryList,当线程获得锁之后monitor中的Owner标记会记录此线程,并在该monitor中的计数器执行递增计算代表当前锁被持有锁定,而没有获取到的线程继续在EntryList中阻塞等待。如果线程调用了wait方法,则monitor中的计数器执行赋0运算,并且将Owner标记赋值为null,代表当前没有线程持有锁,同时调用wait方法的线程进入WaitSet队列中阻塞等待,直到持有锁的执行线程调用notify/notifyAll方法唤醒WaitSet中的线程,唤醒的线程进入EntryList中等待锁的获取。除了使用wait方法可以将修改monitor的状态之外,显然持有锁的线程的同步代码块执行结束也会释放锁标记,monitor中的Owner会被赋值为null,计数器赋值为0。如下图所示

3、锁的种类、升级和对比

a)锁的种类

  Java 中锁的种类大致分为偏向锁,自旋锁,轻量级锁,重量级锁。锁的使用方式为:先提供偏向锁,如果不满足的时候,升级为轻量级锁,再不满足,升级为重量级锁自旋锁是一个过渡的锁状态,不是一种实际的锁类型。锁只能升级,不能降级。

b)锁的升级

①偏向锁

  如果代码中基本不可能出现多线程并发争抢同一个锁的时候,JVM 编译代码,解释执行的时候,会自动的放弃同步信息,消除 synchronized 的同步代码结果,使用锁标记的形式记录锁状态。具体的实现方式大概就是:当一个线程访问同步块并获取锁的时候,会在对象头和栈帧的锁记录中存储偏向的线程ID,之后线程在进入和退出同步块的时候不需要使用CAS进行加锁和解锁,只需要测试对象头中的MarkWord中是否存储着当前线程的偏向锁;如果测试成功,就表示线程获取锁成功,如果测试失败需要检查对象头中的MarkWord的偏向锁表示是否设置为1,如果没有设置就使用CAS竞争锁,设置了就以CAS方式将偏向锁设置为当前线程。在 Monitor 中有变量 ACC_SYNCHRONIZED。当变量值使用的时候,代表偏向锁锁定。使用偏向锁可以避免锁的争抢和锁池状态的维护。提高效率。

②轻量级锁

  当偏向锁不满足,也就是有多线程并发访问,锁定同一个对象的时候,先提升为轻量级锁。也是使用标记 ACC_SYNCHRONIZED 标记记录的。ACC_UNSYNCHRONIZED 标记记录未获取到锁信息的线程。就是只有两个线程争抢锁标记的时候,优先使用轻量级锁。(自旋锁)当获取锁的过程中,未获取到。为了提高效率,JVM 自动执行若干次空循环,再次申请锁,而不是进入阻塞状态的情况。称为自旋锁。自旋锁提高效率就是避免线程状态的变更

③重量级锁

  在自旋过程中,为了避免无用的自旋(比如获得锁的线程被阻塞住了),锁就会被升级为重量级锁。在重量级锁的状态下,其他线程视图获取锁的时候都会被阻塞住,只有持有锁的线程释放锁之后才会唤醒那些阻塞的线程,这些线程就开始竞争锁。

4、关于synchronized的其他说明

a)关于同步方法和非同步方法

同步方法只影响 锁定同一个锁对象的同步方法,不影响非同步方法被其他线程调用,也不影响其他所资源的同步方法(简单理解就是锁的不是同一个资源,就不会影响);

b)synchronized是可重入的

同一个线程,多次调用同步代码,锁定同一个对象,是可重入的;

c)关于同步的继承问题

同一个线程中,子类同步方法覆盖父类的同步方法,可以指定调用父类的同步方法(相当于锁的重入)

d)锁与异常

当同步方法出现异常的时候会自动释放锁,不会影响其他线程的执行

e)synchronized锁的是对象,而不是引用

同步代码一旦加锁之后会有一个临时锁引用执行锁对象,和真实的引用无直接关联,在锁释放之前,修改锁对象引用不会影响同步代码块的执行

f)synchronized中的常量问题

在定义同步代码块的时候,不要使用常量对象作为锁对象

 

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: