侧边栏壁纸
博主头像
落叶人生博主等级

走进秋风,寻找秋天的落叶

  • 累计撰写 130555 篇文章
  • 累计创建 28 个标签
  • 累计收到 9 条评论
标签搜索

目 录CONTENT

文章目录

Java中的锁事

2022-06-30 星期四 / 0 评论 / 0 点赞 / 13 阅读 / 31434 字

Java的锁Java根据不同的特性来对锁进行分类,大概有以下分类方式。这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改

Java的锁

Java根据不同的特性来对锁进行分类,大概有以下分类方式。

这里主要讨论乐观锁和悲观锁以及在Java中对应的实现。

对于同一个数据的并发操作,悲观锁认为自己在使用数据时,一定会有其它线程来修改数据,所以在每次操作数据前都会加上一个锁,以确保没有其它线程来修改数据。Java中的synchronized锁和lock锁都是悲观锁。
而乐观锁每次都认为不会有其它线程来修改数据,所以在操作数据时不会上锁,而是在修改数据时去判断有没有其它线程修改了这个数据,如果没有被修改,则更新成功,如果已经被其它线程修改,则重新尝试或失败。Java中最常用的就是通过CAS算法来实现无锁并发编程。

根据悲观锁和乐观锁的概念可以发现:

  • 悲观锁适合写多读少的场景,因为先加锁能保证写操作的正确性。
  • 乐观锁适合读多写少的场景,因为读操作一般并不需要加锁(没有修改数据),所以乐观锁的无锁特性能使读性能有很大的提升(减少了加锁等待的时间)。

悲观锁

Synchroniezd锁

synchronized是一种互斥锁,也就是悲观锁,每次只允许一个线程进入synchronized修饰的方法或代码块中。synchronized锁是可重入的,即一个线程可以多次获取同一个对象或类的锁。
synchronized通过使用内置锁,对变量进行同步,来保证线程操作的原子性、有序性、可见性,可以确保多线程下的操作安全。
synchronized锁有三种使用方式,分别是对对象加锁(修饰普通方法,锁的是当前类的对象)、对代码块加锁(锁的是非当前类对象)、对类加锁(修饰静态方法,锁的是当前类)。更多:synchronized锁
下面是用synchronized锁,用N个线程循环打印0~M个数字。

public class SynchronizedTest implements Runnable {	// 定义一个对象用来保持锁	private static final Object LOCK = new Object();	// 当前线程	private int threadNum;	// 线程总数	private int threadSum;	// 当前数字,从0开始打印	private static int current = 0;	// 要打印的最大值	private int max;	public SynchronizedTest(int threadNum, int threadSum, int max) {		this.threadNum = threadNum;		this.threadSum = threadSum;		this.max = max;	}	@Override	public void run() {		// 实现N个线程循环打印数字		while (true) {			// 对代码块加锁,保证每次只有一个线程进入代码块			synchronized (LOCK) {				// 当前值 / 线程总数 = 当前线程				// 这里一定要用while,而不能用if。因为当线程被唤醒时,监视条件可能还没有满足(线程唤醒后是从wait后面开始执行)。				while (current % threadSum != threadNum) {					// 打印完了,跳出循环					if (current >= max) {						break;					}					// 不满足打印条件,则让出锁,进入等待队列					try {						LOCK.wait();					} catch (Exception e) {						e.printStackTrace();					}				}				// 这里还要做一次判断				if (current >= max) {					break;				}				System.out.println(Thread.currentThread().getName() + " 打印 " + current);				++current;				// 当前线程打印完了,通知所有等待的线程进入阻塞队列,然后一起去争抢锁				LOCK.notifyAll();			}		}	}	public static void main(String[] args) {		// 开启N个线程		int N = 3;		// 打印M个数字		int M = 15;		for (int i = 0; i < N; ++i) {			new Thread(new SynchronizedTest(i, N, M)).start();		}	}}

Lock锁

lock锁也是一种互斥锁,同时悲观锁。它是一种显示锁,加锁与释放锁的操作都需要手动实现,而synchronized的释放锁是自动实现的。
ReentrantLock锁内部定义了公平锁和非公平锁。对于公平锁,内部维护了一个FIFO队列用来保存进入的线程,保证先进入的线程能先执行。而对于非公平锁,如果一个线程释放了锁,其它所有线程都可以去抢这个锁,这样就会导致有些人可能会饿死,可能永远也得不到执行。但是公平锁为了实现时间上的绝对顺序,需要频繁的切换上下文,而非公平锁会减少一定的上下文切换,降低了开销。所以ReentrantLock默认采用的是非公平锁,以提高性能。
reentrantLock实现可见性是通过AQS中用volatile修饰的state来实现的,下面来分析一下原理(以非公平锁为例)。
reentrantLock先显示上锁,调用lock方法。

final void lock() {    // 先尝试获取锁,也就是将state更新为1(这里用了CAS),如果获取成功,则将当前线程设置为独占模式同步的当前所有者    if (compareAndSetState(0, 1))        setExclusiveOwnerThread(Thread.currentThread());    // 如果获取失败,则进入acquire()方法    else        acquire(1);}

下面进入acquire()方法:

public final void acquire(int arg) {    // 调用tryAcquire尝试获取锁    // 如果获取锁失败,则用当前线程创建一个独占结点加到等待队列的尾部,并继续尝试获取锁    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))        selfInterrupt();}

这里只进入tryAcquire看看:

protected final boolean tryAcquire(int acquires) {    // 内部又调用了一个非公平的尝试获取锁方法    return nonfairTryAcquire(acquires);}

进入往下看:

final boolean nonfairTryAcquire(int acquires) {    // 获取当前线程    final Thread current = Thread.currentThread();    // 重点!首先从主存中获取state(state是个volatile修饰的变量)    int c = getState();    // 如果state为0,说明没有获取过锁    if (c == 0) {        // 尝试获取锁        if (compareAndSetState(0, acquires)) {            // 将当前线程设置为独占模式当前所有者            setExclusiveOwnerThread(current);            return true;        }    }    // 如果state不为0,说明之前获取过锁    else if (current == getExclusiveOwnerThread()) {        // 将锁的数量叠加        int nextc = c + acquires;        if (nextc < 0) // 溢出(超过最大锁的数量)则抛出异常            throw new Error("Maximum lock count exceeded");        // 因为当前线程已经获取了锁,在这一步不会有其它线程来干扰,所以不需要用CAS来设置state        setState(nextc);        return true;    }    return false;}

上面就是获取锁的主要代码,如果获取失败了,将会被加入到等到队列中继续尝试获取锁。(这一步不再分析)
下面再来看看释放锁的过程:

public void unlock() {    // 通过内部类调用父类AbstractQueuedSynchronizer的release方法    sync.release(1);}

下面进入release方法:

public final boolean release(int arg) {    // 调用tryRelease方法来尝试释放锁    if (tryRelease(arg)) {        Node h = head;        // 如果头节点不为空且等待状态非0        if (h != null && h.waitStatus != 0)            // 如果头节点的后继节点存在,则唤醒它            unparkSuccessor(h);        return true;    }    return false;}

reentrantLock的内部类sync重写了tryRelease方法:

protected final boolean tryRelease(int releases) {    // 重点!也是首先获取state,并减去要释放的锁的数量    int c = getState() - releases;    // 如果当前线程不等于当前独占模式拥有者线程    if (Thread.currentThread() != getExclusiveOwnerThread())        // 抛出一个非法监视器状态异常        throw new IllegalMonitorStateException();    boolean free = false;    // 如果持有锁的数量为0    if (c == 0) {        // 设置锁为可释放        free = true;        // 把当前独占线程清空        setExclusiveOwnerThread(null);    }    // 设置state    setState(c);    return free;}

以上就是释放锁的关键代码

通过以上分析可知,每次在加锁和释放锁的时候,都会进入方法时先获取state,最后以设置state结束。
由于state变量是通过volatile修饰的,所以state对于所有线程是可见的,又因为volatile变量在每次强制刷新到主内存的时候,会将非volatile变量也刷新回主存。
在加锁的代码中,肯定是先调用lock(由于操作了volatile的state(先读后写),会强制刷新主存),最后调用unlock(也要操作state,会再次强制刷新主存),根据happens-before规则,volatile变量的写对于下一次的读是可见的。所以这保证了同步代码中的共享变量是可见的。

下面是一个利用reentrantLock实现的循环交替打印ABC,其中还使用了locks的条件变量condition。

import java.util.concurrent.locks.Condition;import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockTest {	// 定义一个显示锁	private static ReentrantLock lock = new ReentrantLock();	// 监控a的条件变量	private static Condition a = lock.newCondition();	// 监控b的条件变量	private static Condition b = lock.newCondition();	// 监控c的条件变量	private static Condition c = lock.newCondition();	// 控制要打印的值	private static int flag = 0;	public static void printA() {		for (int i = 0; i < 5; i++) {			// 显示加锁			lock.lock();			try {				try {					while (flag != 0) {						// 不满足监视条件则等待						a.await();					}					System.out.println(Thread.currentThread().getName() + "我是A");					flag = 1;					// 通知b线程去打印					b.signal();				} catch (Exception e) {					e.printStackTrace();				}			} finally {				// 释放锁				lock.unlock();			}		}	}	public static void printB() {		for (int i = 0; i < 5; i++) {			lock.lock();			try {				try {					while (flag != 1) {						b.await();					}					System.out.println(Thread.currentThread().getName() + "我是B");					flag = 2;					c.signal();				} catch (Exception e) {					e.printStackTrace();				}			} finally {				lock.unlock();			}		}	}	public static void printC() {		for (int i = 0; i < 5; i++) {			lock.lock();			try {				try {					while (flag != 2) {						c.await();					}					System.out.println(Thread.currentThread().getName() + "我是C");					flag = 0;					a.signal();				} catch (Exception e) {					e.printStackTrace();				}			} finally {				lock.unlock();			}		}	}	public static void main(String[] args) {		new Thread(() -> {			printA();		}).start();		new Thread(() -> {			printB();		}).start();		new Thread(() -> {			printC();		}).start();	}}

synchronized锁和reentrantLock锁

相同点

  • 都能对资源加锁,保证线程间的同步访问。
  • 都是可重入锁,即一个线程能多资源反复加锁。
  • 都保证了多线程操作的原子性、有序性、可见性(这个只能保证共享变量在加锁操作内的可见性,而在加锁操作外的可见性不能得到绝对的保证,因为锁外不能保证一直从主存中获取数据,工作内存可能会不同步)

不同点

  • 同步实现机制不同
    • synchronized通过java对象内部的monitor锁实现(依赖操作系统的mutexLock实现,线程会被直接阻塞)
    • reentrantLock通过AQS来实现(线程不会被直接阻塞,先CAS自旋获取锁,当自旋时间过长,才会将线程阻塞)
  • 可见性实现机制不同
    • synchronized通过java内存模型来保证可见性
    • reentrantLock通过AQS的state(volatile修饰的)来保证可见性
  • 监控条件不同
    • synchronized通过java对象作为监控条件
    • reentrantLock通过Condition(提供 await、signal 等方法)作为监控条件
  • 使用方式不同
    • synchronized可用来修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、同步代码块(锁住指定的对象)
    • reentrantLock需要显示的调用lock加锁,并需要在finally中释放锁
  • 功能丰富程序不同
    • synchronized只是简单的加锁
    • ReentrantLock 提供定时获取锁、可中断获取锁、Condition(提供 await、signal 等方法)等特性。
  • 锁类型不同
    • synchronized只支持非公平锁。
    • reentrantLock支持公平锁和非公平锁,但是非公平锁效率更高

在 synchronized 优化以前,它比较重量级,其性能比 ReentrantLock 要差很多,但是自从 synchronized 引入了偏向锁、轻量级锁(自旋锁)、锁消除、锁粗化等技术后,两者的性能就相差不多了。

优化后的synchronized

synchronized不管是对方法、对象还是类进行加锁,实际上锁的都是对象(实例对象或类对象),锁的信息会保存在对象头的mark work中。在对象刚被创建的时候,锁标志是偏向锁,但它还没有生效(也就是还没加锁)。

  1. 当线程执行到临界区(也就是同步代码块)的时候,会用CAS操作给对象加上偏向锁(加了一次锁后,如果没有其它线程来竞争锁,那么当它再次进入同步代码块时不需要进行第二次加锁)。
  2. 如果有两个线程来竞争锁的时候,偏向锁会失效,而升级为轻量级锁,轻量级锁是一种非阻塞同步的乐观锁。
    • 自旋锁:当有其它线程来竞争锁时,这个线程不会被阻塞,而是会在原地循环等待(默认是10次,可以通过虚拟机参数更改),直到前面获取锁的线程释放锁后(大部分同步代码块执行时间都很短),这个线程就可以获得锁了。
    • 自适应自旋锁:与自旋锁的区别就是循环的次数不是固定的,而是虚拟机根据实际情况来定的,如果线程刚刚获得过该锁,那么虚拟机认为它很有可能再次成功获得锁,所以会增加该线程的自旋次数,如果线程几乎没有获得过锁,那么虚拟机会减少线程自旋的次数或者直接忽略自旋。
  3. 如果自旋次数超了,那么轻量级锁会失效,锁会再次升级,变成重量级锁。重量级锁就是优化前的synchronized锁,它是依赖对象内部的monitor锁实现的,而monitor锁又是依赖操作系统mutexLock(互斥锁)实现的。那么现在有线程来竞争锁时,会直接被阻塞掉,因为Java中的线程都是映射到轻量级进程上的,它又是基于内核线程实现的,所以线程的阻塞和唤醒等操作都需要系统调用(需要在用户态和内核态之间来回切换,这会消耗很多CPU的,所以说重量级锁的开销很大)

乐观锁

CAS算法

即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三个操作数:

  • 需要读写的内存值V(在内存中的值)
  • 进行比较的值A(输入的值)
  • 要写入的新值B(要更新的值)

当且仅当值V等于值A时,CAS通过原子方式将值V更新为B(比较并替换是一个原子操作,unsafe底层通过操作系统来保证原子性),如果V不等于A,则失败或重试。这里没有涉及到锁操作,所以是很高效的。
但是它存在三个问题:

  • 循环开销大。CAS如果长时间操作不成功(写的并发量比较大),会导致长时间自旋,从而造成CPU资源的浪费。
  • 只能保证一个变量的原子操作。但是开始JDK1.5提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
  • ABA问题。如果CAS先把值改为B,又改回A。在CAS看来这个值是没有变化的,但实际上是变化了的。最典型的就是ATM取钱问题:余额100,我取出50,此时ATM开了两个线程,但是一个线程暂时挂了,一个线程成功把余额更新为50,然后我朋友又给我转了50,此时余额为100,但是刚刚那个取钱的线程又活了,继续刚刚的操作,尝试将100更新为50,emmm,在CAS看来它是可以成功的,但这是不符合逻辑的(我朋友转给我的50块去哪啦???)。ABA问题的一般解决思路就是在变量前加个版本号,这样更新操作就变成了1A->2B->3A,这样CAS就会认为他们不一样了。JDK1.5开始提供AtomicStampedReference中引入了标志,这个类的compareAndSet()方法中需要当前标志和预期标志相同才能更新成功(每次更新时都会更新这个标志)。

这里结合AtomicStampedReference和CountDownLatch实现一个ABA的例子(通过版本号可以解决问题)

import java.util.concurrent.CountDownLatch;import java.util.concurrent.atomic.AtomicStampedReference;public class CasTest {    // 定义一个原子类型变量	private static AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(1, 0);    // 定义一个线程计数器	private static CountDownLatch countDownLatch = new CountDownLatch(1);	public static void main(String[] args) {		new Thread(() -> {			// 打印当前线程的值				System.out.println("线程 " + Thread.currentThread().getName()						+ " value" + asr.getReference());				// 最开始的版本				int stamp = asr.getStamp();				try {					// 等待其它线程全部执行完毕(这里只需等待线程2运行结束)					countDownLatch.await();				} catch (Exception e) {					e.printStackTrace();				}				// 将1改为2,又改为1后,再次尝试将最开始的版本的1修改为2				// 操作结果应该是失败的,因为当前版本(0)与预期版本(2)不同				// 如果将取版本号的操作放在当前,操作结果肯定是成功的(因为这里修改的1不是最开始版本的1)				System.out.println(Thread.currentThread().getName()						+ " CAS操作结果 "						+ asr.compareAndSet(1, 2, stamp, stamp + 1));			}, "线程1").start();		new Thread(() -> {			// 把值修改为2				asr.compareAndSet(1, 2, asr.getStamp(), asr.getStamp() + 1);				System.out.println("线程 " + Thread.currentThread().getName()						+ " value" + asr.getReference());				// 把值修改为1				asr.compareAndSet(2, 1, asr.getStamp(), asr.getStamp() + 1);				System.out.println("线程 " + Thread.currentThread().getName()						+ " value" + asr.getReference());				// 当前任务执行完毕,等待线程数减1				countDownLatch.countDown();			}, "线程2").start();	}}
.
.

广告 广告

评论区