线程中的锁

在这里插入图片描述

悲观锁和乐观锁

悲观锁(Pessimistic Lock)

顾名思义,悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作,直到前面一个人把锁释放后下一个人数据加锁才可对数据进行加锁,然后才可以对数据进行操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的;

特点:可以完全保证数据的独占性和正确性,因为每次请求都会先对数据进行加锁, 然后进行数据操作,最后再解锁,而加锁释放锁的过程会造成消耗,所以性能不高;

手动加悲观锁:读锁LOCK tables test_db read释放锁UNLOCK TABLES;

写锁:LOCK tables test_db WRITE释放锁UNLOCK TABLES;

悲观锁的实现:

  • 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
  • Java 里面的同步 synchronized 关键字的实现。
  • 悲观锁主要分为共享锁和排他锁:

1.共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
2.排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
3,说明
悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

乐观锁

乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现);

特点:乐观锁是一种并发类型的锁,其本身不对数据进行加锁通而是通过业务实现锁的功能,不对数据进行加锁就意味着允许多个请求同时访问数据,同时也省掉了对数据加锁和解锁的过程,这种方式大大的提高了数据操作的性能;
在这里插入图片描述
乐观锁的实现:

  • CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
  • 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

如何选择

在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。

  • 响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
  • 冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
  • 重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
  • 乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。

随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。

独占锁和共享锁

独占锁

指该锁一次只能被一个线程锁持有。对ReentrantLock 和 Synchronized 而言都是独占锁。

共享锁

值该锁可被多个线程所持有。
对 ReentrantReadWriteLock 其读锁是共享锁,其写锁是独占锁。

读锁的共享锁可保证并发读是非常高效的,读写,写写,写读的过程都是互斥的。

互斥锁和读写锁

互斥锁

互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。

读写锁

读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。

读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。

读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。

公平锁和非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买,后来的人在队尾排着,这是公平的。

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的状态(某个线程一直得不到锁)。

饥饿和公平

如果一个线程因为其他线程抢占了所有的CPU时间,它就被称为“饥饿”(starvation)。解决饥饿的方案被称为“公平”(fairness)——所有的线程公平的获得执行的机会。

引发饥饿

在Java中,通常由下面三个主要的原因导致线程饥饿

  • 高优先级的线程占用了所有的CPU时间
  • 线程无限期的等待进入一个同步代码块,因为其他线程一直被允许进入这个同步代码块
  • 线程持续的在一个对象上等待,由于其他线程持续的被唤醒

在Java中线程间实现公平竞争

在Java中,100%实现线程间公平竞争是不可能的,但是我们仍然可以通过实现我们的同步结果增加线程间的公平性。

首先,让我们学习一个简单的同步代码块:

public class Synchronizer{
    public synchronized void doSynchronized(){
	    // do a lot of work which takes a long time
    }
}

如果,多个线程调用doSynchronized()方法,其中一些线程将会阻塞等待直到第一个进入这个方法的线程离开这个方法。在等待的多个线程中,接下来谁会获得进入同步代码块的机会是不确定的。

使用锁代替同步代码块

public class Synchronizer{
    Lock lock = new Lock();

	public void doSynchronized()throws InterruptedException{
		this.lock.lock();
		//critical section, do a lot of work which takes a long time
		this.lock.unlock();
	}
}

注意,现在doSynchronized()不再被声明为synchronized

Lock类的一个简单的实现:

public class Lock{
    private boolean isLocked = false;
    private Thread lockingThread = null;


	public synchronized void lock()throws InterruptedException{
		while(isLocked){	
			wait();
		}
		isLocked = true;
		lockingThread = Thread.currentThread();
	}


	public synchronized void unlock(){
		if(this.lockingThread != Thread.currentThread()){
			throw new IllegalMonitorException("Calling thread has not lock this lock");
			
		}

		isLocked = false;
		lockingThread = null;
		notify();
	}
}

如果你看上面Synchronizer类中锁的实现,你会发现,如果多个线程同时调用lock()方法,尝试访问lock()方法的线程将会阻塞。第二,如果这个锁被锁住了,这个线程将被阻塞在lock()方法的while循环里的wait()方法中。

Remember that a thread calling wait() releases the synchronization lock on the Lock instance, so threads waiting to enter lock() can now do so. The result is that multiple threads can end up having called wait() inside lock().

If you look back at the doSynchronized() method you will notice that the comment between lock() and unlock() states, that the code in between these two calls take a “long” time to execute. Let us further assume that this code takes long time to execute compared to entering the lock() method and calling wait() because the lock is locked. This means that the majority of the time waited to be able to lock the lock and enter the critical section is spent waiting in the wait() call inside the lock() method, not being blocked trying to enter the lock() method.

As stated earlier synchronized blocks makes no guarantees about what thread is being granted access if more than one thread is waiting to enter. Nor does wait() make any guarantees about what thread is awakened when notify() is called. So, the current version of the Lock class makes no different guarantees with respect to fairness than synchronized version of doSynchronized(). But we can change that.

The current version of the Lock class calls its own wait() method. If instead each thread calls wait() on a separate object, so that only one thread has called wait() on each object, the Lock class can decide which of these objects to call notify() on, thereby effectively selecting exactly what thread to awaken.

公平锁

如果将上面的Lock类改为一个公平的锁,我们称之为公平锁(FairLock)。你会发现,仅做了一点小小的修改。

public class FairLock{
    private boolean isLocked = false;
    private Thread lockingThread = null;
    private List<QueueObject> waitingThreads = new ArrayList<QueueObject>();


	public void lock()throws InterruptedException{
		QueueObject queueObject = new QueueObject();
		boolean isLockedForThisThread = true;

		synchronized(this){
			waitingThreads.add(queueObject);
		}

		while(isLockedForThisThread){
			synchronized(this){
				isLockedForThisThread = isLocked || waitingThreads.get(0) != queueObject;
				if(!isLockedForThisThread){
					isLocked = true;
					waitingThreads.remove(queueObject);
					lockingThread = Thread.currentThread();
					return;
				}
			}

			try{
				queueObject.doWait();
			}catch(InterruptedException e){
				synchronized(this){
					waitingThreads.remove(queueObject);
					throw e;
				}
			}
		}
	}

	public synchronized void unlock(){
		if(this.lockingThread != Thread.currentThread){
			throw new IllegalMonitorException("Calling thread has not locked this lock");
		}

		isLocked = false;
		lockingThread = null;
		if(waitingThreads.size() > 0){
			waitingThreads.get(0).doNotify();
		}
	}
}


public class QueueObject{
    private boolean isNotified = false;

	public synchronized void doWait()throws InterruptedException{
		while(!isNotified){
			this.wait();
		}

		this.isNotified = false;
	}

	public synchronized void doNotify(){
		this.isNotified = true;
		this.notify();
	}

	public boolean equals(Object o){
		return this == o;
	}
}

首先,你可能注意到lock()方法不再被声明为synchronized。只有需要同步的代码块才被嵌套在同步代码块。

FairLock创建了一个新的QueueObject实例,每一个线程调用lock()时将其入队。当线程调用unlock()方法时,将会取出队列顶部的QueueObject实例,然后用它调用doNotify()方法,去唤醒等待在该对象上线程。这种方式,在某一时刻,仅唤醒一个线程,而不是所有的线程。

注意,锁的状态任然在同一个同步代码块中被测试和设置,为了避免slipped conditions。

QueueObject确实是一个信号量。doWait()doNotify()方法在QueueObject内部存放信号。这么做是为了避免由于一个线程在调用queueObject.doWait()之前,另一个线程调用了unlock(),进而调用queueObject.doNotify()而被抢占带来的丢失信号的现象。queueObject.doWait()调用为了避免nested monitor lockout而被放在了同步代码块的外面,因此,其他线程在没有线程运行在locvk()方法内的synchronized(this)代码块中时可以真正的调用unlock

最后,注意queueObject.doWait()如何在一个try-catch块中被调用的。

性能比较

如果你比较Lock类和FairLock类,你会注意到在fairLock类中的lock()unlock方法中多一些代码。这些额外的代码将会导致FairLock变成一个比Lock稳定一些的同步机制。这种影响的大小取决于你的程序执行被FairLock监视的临界区里的代码花费的时间。 The longer this takes to execute, the less significant the added overhead of the synchronizer is. It does of course also depend on how often this code is called.

可重入锁

可重入锁又称之为递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁。对于Synchronized而言,也是一个可重入锁。

敲黑板:可重入锁的一个好处是可一定程度避免死锁。

以 synchronized 为例,看一下下面的代码:

public synchronized void mehtodA() throws Exception{
// Do some magic tings
mehtodB();
}

public synchronized void mehtodB() throws Exception{
// Do some magic tings
}
上面的代码中 methodA 调用 methodB,如果一个线程调用methodA 已经获取了锁再去调用 methodB 就不需要再次获取锁了,这就是可重入锁的特性。如果不是可重入锁的话,mehtodB 可能不会被当前线程执行,可能造成死锁。

自旋锁

自旋锁是指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。
自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。

如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。

在 Java 中,AtomicInteger 类有自旋的操作,我们看一下代码:

public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
CAS 操作如果失败就会一直循环获取当前 value 值然后重试。

另外自适应自旋锁也需要了解一下。

在JDK1.6又引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。

分段锁

分段锁 是一种锁的设计,并不是具体的一种锁。

分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

锁升级(无锁|偏向锁|轻量级锁|重量级锁)

JDK1.6 为了提升性能减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。

无锁

无锁状态其实就是上面讲的乐观锁,这里不再赘述。

偏向锁

Java偏向锁(Biased Locking)是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。

偏向锁的实现是通过控制对象Mark Word的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入。

轻量级锁

当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁。

重量级锁

如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。

升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。

锁优化技术(锁粗化、锁消除)

锁粗化

锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。

举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作。

private static final Object LOCK = new Object();

for(int i = 0;i < 100; i++) {
synchronized(LOCK){
// do some magic things
}
}
经过锁粗化后就变成下面这个样子了:

synchronized(LOCK){
for(int i = 0;i < 100; i++) {
// do some magic things
}
}

锁消除

锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。

举个例子让大家更好理解。

public String test(String s1, String s2){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(s1);
stringBuffer.append(s2);
return stringBuffer.toString();
}
上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。

test 方法中三个变量s1, s2, stringBuffer, 它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的。

我们都知道 StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除。

StringBuffer.class

// append 是同步方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}

在Java多线程的使用是为了控制对共享资源的访问,以避免多个线程同时对同一资源进行修改而导致数据不一致或竞态条件的问题。Java提供了两种的机制:synchronized关键字和Lock接口。 1. synchronized关键字: - synchronized关键字可以用来修饰方法或代码块,使其成为同步方法或同步块。 - 当一个线程访问同步方法或同步块时,会自动获取该方法或代码块所在对象的,并在执行完后释放。 - 其他线程在获取之前会被阻塞,直到被释放。 - 示例代码: ```java public synchronized void synchronizedMethod() { // 同步方法 } public void synchronizedBlock() { synchronized (this) { // 同步块 } } ``` 2. Lock接口: - Lock接口是Java提供的显示机制,提供了更灵活的定方式。 - Lock接口的常用实现类是ReentrantLock,它具有与synchronized相似的语义。 - 示例代码: ```java Lock lock = new ReentrantLock(); public void lockMethod() { lock.lock(); try { // 加的代码 } finally { lock.unlock(); // 必须在finally块释放,以防止异常导致无法释放 } } ``` 在使用时,需要注意以下几点: - 的粒度应尽量小,只定必要的代码块,以减少线程间的竞争。 - 避免死,即多个线程相互等待对方释放的情况。 - 保证的正确使用,避免忘记释放或错误地释放,可以使用try-finally语句块来确保的释放。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值