最全锁种类

你可能听说过很多锁,也看到过很多文章讲解锁,这篇我在这里将对锁的不同分类进行描述

锁的设计

  1. 互斥锁–共享锁 互斥锁:顾名思义,就是互斥的,意思就是当前同步代码块只能被一个线程访问,sync、reentrantlock、writelock都是互斥锁

    共享锁:与互斥锁相反,就是当前同步代码块可以被多个线程同时访问。目前只知道Readlock,通过condition实现了共享锁。

  2. 公平锁–非公平锁 公平锁:就是在获取锁的时候,哪个线程等的时间长就优先调用哪个线程,reentrantlock可实现公平锁,通过队列来实现控制

    非公平锁:就是随机的了,所以吞吐量比公平锁高。sync锁默认非公平锁,不可修改,reentrantlock默认非公平锁,可修改为公平锁

  3. 可重入锁–不可重入锁 可重入锁:当前线程已经持有锁,在同步代码块中调用其他的锁的方法(锁的对象是同一个)时,可以无条件进入。可重入锁的优点就是,可以避免死锁 举个例子:A和B两个方法,锁的对象都是同一个,在A方法同步代码中调用B方法,可以进入B方法执行,称为可重入锁。可以看一下ReentrantLock中的实现。

    不可重入锁:就是不能进入。也是可以通过ReentrantLock锁中重写AQS中的tryAcquire方法:当判断锁状态不为0时,则直接返回,省略判断与持有锁的线程是否相等这一步,即可得到非重入锁

  4. 乐观锁–悲观锁:在获取的时候,不上锁,但是在set的时候,通过某种方式的操作,保证了数据的正确性,乐观锁的乐观所体现的地方就是,在get的时候不上锁,认为在我操作期间,不会有其他线程对数据进行操作。但是又不得不防止会出现有其他线程干扰的操作,最常见的就是CAS算法实现数据正确性,最常见的例子就是在数据库中设计版本号字段,通过对他进行操作,实现CAS算法。

    悲观锁,那就是在乐观锁体现乐观的地方的相反,就是在获取的时候,就上锁,这个就不用多说了。

  5. 死锁–活锁–饥饿:是程序上由于设计错误或者不合理导致的,其中一种情况就是T1线程得到A资源,但是又必须获取B资源才能继续业务操作,此时T2线程得到了B资源,也是必须要获取到A资源才能继续业务操作,这种你咬我、我咬着你,但又都不释放锁的情况称为死锁。*
    死锁可以概括为,就是某个线程在同步代码块中,获取不到相关资源,导致进行不下去,但又释放不了的情况。*,预防死锁:一次上锁、顺序上锁。

    活锁:是当T1线程获取锁,T2线程过来,那么T1将锁让给T2,然后T3线程过来,T2又让给T3,这样互相让,就是活锁。预防:先来先服务,不让

    饥饿:我们知道线程有优先级的设置,如果此时T1线程获取锁,T2线程来了并且优先级高于T1,此时T2处理完后,本该交给T1,但是此时又来了T3,同样T3的线程优先级高于T1,T4…这样的情况,T1被称为饥饿。

  6. 读写锁:读锁是共享锁,写锁是互斥锁(排他锁),读读不互斥、读写互斥、写写互斥,在ReentrantLock中通过condition实现,多个条件队列,一个同步队列,大概情况是:每一次new Condition就是一个队列,当线程调用await的时候,将线程封装成节点,加入到这个队列中,当其他线程调用singalAll的时候,将条件队列中的节点加入到同步队列中,这里有具体的代码示例。我在这里只提取部分代码,进行说明,:

ReentrantReadWriteLock lock2 = new ReentrantReadWriteLock();
private Condition producerCondition = lock.newCondition();//生产者队列
private Condition consumerCondition = lock.newCondition();//消费者队列

public void producer(Object o) {
		while(list.size()==maxValue) {
			try {
//				this.wait(); wait 是配合synchronized锁用的
				//执行到这一步的线程,将线程封装成节点,加入到生产者
				//队列中
				producerCondition.await();
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
		list.add(o);
//		this.notifyAll();
		//将消费者队列中的节点,加入到同步队列中,进行处理。
		consumerCondition.signalAll();
	}

锁的优化

自旋锁、自适应自旋锁、偏向锁、轻量级锁、重量级锁涉及到的是对sync锁的优化,在这里放到一起说,您可以串起来,更容易理解,他们是sync锁在进行优化过程中的阶段性状态

定义

  1. 自旋锁 就是在获取到锁之前不会进入阻塞,不会释放时间片,而是在那自旋,直到获取锁,在jdk中,1.4的版本引入自旋锁,通过-XX:UseSpinning参数开启,不过在1.6中,就改为默认开启了。自旋的此时默认为10次,用户可以使用-XX:PreBlockSpin来更改,不过在1.6中对自旋锁又进行了优化,引入了自适应的自旋锁,也就是自旋时间不再固定,而是由前一个在同一个锁上的自旋时间及锁的拥有者的状态来决定,可以理解为由jvm控制把。
  2. 偏向锁 偏向锁就是偏心的偏。也是对sync锁进行的优化,
  3. 轻量级锁–重量级锁 轻量级锁:就是线程不用阻塞,通过cas进行自旋获取锁。其实轻量级锁表示的意思就是现在这个锁已经存在了竞争。 重量级锁:当线程获取不到锁时,进入阻塞状态,因为涉及到线程上下的切换(就是用户态切换成内核态),而这很消耗性能,所以才被称为“重量级锁”。为什么需要用户态切换成内核态呢?下面会详细解释。

通过对一个对象的操作,不同的状态表示同步代码是否存在竞争关系,及竞争关系的强烈。(请理解这句话)

现在jdk自带的虚拟机是hotspot虚拟机,该虚拟机规定了,每一个对象都有一个对象头,对象头包含了多种不同的信息,其中就包含对象锁相关的信息:

  1. 第一个字宽也被称为对象头Mark Word。 对象头包含了多种不同的信息, 其中就包含对象锁相关的信息。

  2. 第二个字宽是指向定义该对象类信息(class metadata)的指针

    注:这是非数组类型的占用的字宽,数组类型有3个字宽

对象头的设计:

在这里插入图片描述

锁在不同阶段下的状态时,对应的对象头的信息:

在这里插入图片描述

记住这些信息,下面会有用到。

第一阶段 偏向锁
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,程序认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作,偏向锁是1.6中引入的一项锁优化,通过参数设置启用:
-XX:+UseBiasedLocking。

通俗的解释就是,当第一个线程访问它的时候,它会设置成偏向锁,并通过CAS将对象头中的threadId改成当前线程的id,再访问的时候,只进行threadId的对比,不进行更新,以此来实现偏向锁的实现。----感觉有点像重入锁的设计

偏向锁的释放:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行。也就是说即使线程已经运行结束了,偏向锁仍然持有。

第二阶段 轻量级锁
一旦有第二个线程访问这个对象,偏向锁使用了一种等到竞争出现才释放锁的机制,所以偏向锁不会主动释放,因此第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

通俗的解释就是,另一个线程来访问这个对象,如果此时偏向锁的线程依然存活并仍然需要持有偏向锁,则锁升级为轻量级锁。此时当前线程进行自旋获取锁。也就是说,当存在两个或以上的线程同时竞争锁的时候,则升级为轻量级锁。在这里,通过自旋获取锁,就是自旋锁。

轻量级锁解锁:轻量级解锁时,会使用原子的 CAS 操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

第三阶段 重量级锁
当自旋一定次数,仍获取不到锁,则升级为重量级锁,那哪一次才会升级成重量级锁呢。那就得看第一次自旋获取锁失败是什么时候,有可能在只有两个线程同时访问的时候,也有可能在之后的当多个线程同时访问的时,在第某个线程的时候。一切都看第一次自旋获取锁失败的时候。

参考博客:https://blog.csdn.net/choukekai/article/details/63688332

用户态切换内核态

  1. 首先我们得了解为什么会有用户态和内核态 为了限制不同的程序之间的访问能力,以及防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU划分出两个权限等级:用户态和内核态。你完全可以理解为角色,两个角色的权限不同,内核态比用户态的权限高。
    内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
    用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。

  2. 再来说一下,为什么线程阻塞就要进行切换 硬件设计:在JMM内存模型中已经描述了CPU缓存的存在,CPU中还包含了一个叫寄存器的东西,寄存器是CPU内部的元件,是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。用通俗的话说,就是当前线程执行的情况:当前运行到的位置等信息。而当线程进行阻塞时,cpu需要将当前线程的情况写到一块叫PCB的内存上,以便下次被再次读取进来的时候,继续从原来的执行情况执行下去,在将当前需要阻塞的线程写进去后,还要将上次写进去的线程读进来,读进来之后,cpu还要从内核态切换到用户态,也就是涉及到了两次上下文的切换。而这期间涉及到了PCB内存,也就是说cpu将当前线程写入pcb内存,并从pcb内存中读取之前被放入的线程,从上面内核态的解释来看,对pcb内存的操作,需要内核态的权限,用户态没有权限,所以,线程的阻塞才会涉及到上下文的切换(上下文切换指的就是从用户态切换到内核态),并且你也看到了,也需要将之前的线程读取到寄存器中,所以你应该也能想到调用notify方法时,也涉及到了上下文的切换。故:线程调用wait和notify方法时,都很消耗性能,所以,引入了自旋锁,不让它进行阻塞。

自旋锁的实现之CAS算法

jdk的自旋在1.4中引入,但CAS在1.5之后,才被引入,所以可以肯定的是在1.4的自旋锁肯定不是采用的CAS,但是在1.5之后,就不清楚了,没意义。自旋锁有多中实现方式,请自行google,在这里我只简单记下CAS算法实现自旋锁:

CAS算法在1.5之后,java程序才可以使用CAS操作,该操作有unsafe类中的几个方法提供,CAS算法最终被编译的是一条指令,并不是多条指令,所以不会发生线程安全问题的,意思就是:cas算法在底层原始计算机指令中,是原子性的。

CAS:compare and swap 比较并交换,有三个参数:V(内存地址)、A(新值)、B(旧值),cas的一次操作就是,根据内存地址获取此时的值,用旧值与此时的值比较,如果相等,就会认为在它操作期间,没有被其他线程操作过,然后就将新值A根据内存地址进行更新。也正因为这样,所以CAS算法另一方面有名的就是ABA问题,虽然CAS算法执行过程中,不会发生线程安全问题,但是多个线程可以调用CAS算法啊,你可以理解为:在线程没有执行到CAS算法那一步的时候,CPU会执行其他线程,说那么多没用,看图:
在这里插入图片描述
那出现问题总不能不解决吧,那怎么解决ABA问题呢,就是加版本号,类似于乐观锁,看代码:

public class Test {
    private static AtomicInteger atomicInt = new AtomicInteger(100);
    private static AtomicStampedReference atomicStampedRef = new AtomicStampedReference(100, 0);

    public static void main(String[] args) throws InterruptedException {
       Thread intT1 = new Thread(new Runnable() {
           @Override
           public void run() {
              atomicInt.compareAndSet(100, 101);
              atomicInt.compareAndSet(101, 100);
           }
       });

       Thread intT2 = new Thread(new Runnable() {
           @Override
           public void run() {
              try {
                  TimeUnit.SECONDS.sleep(1);
              } catch (InterruptedException e) {
              }
              boolean c3 = atomicInt.compareAndSet(100, 101);
              System.out.println(c3); // true
           }
       });

       intT1.start();
       intT2.start();
       intT1.join();
       intT2.join();

       Thread refT1 = new Thread(new Runnable() {
           @Override
           public void run()
              try {
                  TimeUnit.SECONDS.sleep(1);
              } catch (InterruptedException e) {
              }
              atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
              atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
           }
       });

       Thread refT2 = new Thread(new Runnable() {
           @Override
           public void run() {
              int stamp = atomicStampedRef.getStamp();
              try {
                  TimeUnit.SECONDS.sleep(2);
              } catch (InterruptedException e) {
              }
              boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp + 1);
              System.out.println(c3); // false
           }
       });

       refT1.start();
       refT2.start();
    }
}

其他方面对锁的优化

  1. 锁消除:jdk在其他方面对锁也做了一些优化–锁消除:就是我们代码上要求同步,但是虚拟机即时编译期在运行时,检测到不可能存在共享数据竞争的时候,对锁进行消除,由jvm控制。在这里,不想探究。

  2. 锁粗化:另一方面的优化,是说我们在写代码的时候了。大部分情况下,将同步块的范围控制的尽量小,这样等待锁的线程也能尽快获得锁。可是,当存在一系列连续的操作的时候,那就会对同一个对象反复加锁和解锁,比如for{sysnc{}},频繁的进行同步操作导致不必要的性能消耗。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值