线程基础知识(三)

前言

之前两篇文章介绍了线程的基本概念和锁的基本知识,本文主要是学习同步机制,包括使用synchronized关键字、ReentrantLock等,了解锁的种类,死锁、竞争条件等并发编程中常见的问题。

一、关键字synchronized

  1. synchronied关键字可以把任意一个非null的对象当做锁,属于独占式的悲观锁。同时属于可重入锁
  2. 早期的的synchronized属于重量级的锁,效率低下,因为监视器是依赖底层的操作系统Lock实现的,从6之后java对sychronized进行了优化,jdk1.6以后还引入了 大量的优化,比如自旋锁,适应性锁,锁消除,锁粗化,偏向锁,轻量级锁等。

1.synchronized用法

常用来保证代码的原子性,主要有三种使用方法

  • 修饰实例:作用于当前的对象实例加锁,进入同步代码前获得,当前对象实例的锁
synchronized void method() {
//业务代码
}
  • 修饰静态方法: 也就是给当前类加锁,会作用于该类所有的对象实例。如果线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为静态synchronized方法是占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象的锁。
synchronized void staic method() {
//业务代码
}
  • 修饰代码块: 指定加锁对象,对给定的对象/类加锁,synchronized(this object)表示进入同步前要获得给定对象的锁,synchronized(类.class)表示进入同步前要获得给定类class的锁
synchronized(this) {
//业务代码
}

2. synchronized实现原理

  • 使用synchronized是不用我们去加锁和释放lock,unlock,是jvm已经代替去做了
  • synchronized修饰代码块的时候,jvm是使用monitorenter和monitorexit两个指令实现的(监视器)
    线程基础知识(三)_第1张图片
  • 当修饰同步方法,jvm采用ACC_SYNCHRONIZED标记符来实现的同步, 这个标识表面了这是一个同步方法

线程基础知识(三)_第2张图片

3.synchronized锁住的原理

monitorenter,monitorexit,ACC_SYNCHRONIZED都是基于monitor(监视器)
所谓的Monitor其实是一种同步工具,也可以说是一种同步机制。在Java虚拟机(HotSpot)中,Monitor是由
ObjectMonitor实现的,可以叫做内部锁,或者Monitor锁。
ObjectMonitor的工作原理:
ObjectMonitor有两个队列:WaitSet、EntryList,用来保存ObjectWaiter 对象列表。
_owner,获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用了wait() 方法,此时会释放Monitor 对象, _owner 恢复为空, _count - 1。同时该等待线程进入 _WaitSet 中,等待被唤醒。
-同步是锁住的

  • monitorenter,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法的线程会优先拥有 Monitor 的owner ,此时计数器+1。
  • monitorexit,当执行完退出后,计数器-1,归 0 后被其他进入的线程获得

4.除了原子性,synchronized的可见性和有序性,可重入性怎么实现

  1. 可见性:线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量的时候,需要从主内存中重新读取最新的值。线程加锁后,其他线程无法获得主内存中的共享变量的值,线程解锁前必须把共享变量的最新值刷新到主内存中。
  2. 有序性:synchronized同步的代码块具有排他性,一次只能被一个线程拥有,所以可以保证同一个时刻,代码是单线程执行的,因为as-if-serial存在,单线程语句是能够保证最终结果是有序的,但是不保证不会进行指令重排,所以synchronized是保证有序是执行结果的有序而不是防止指令重排的有序性。
  3. 可重入性:synchronized是可重入锁,也就说允许一个线程二次请求自己持有的锁的临界资源,这种情况就是可重入锁,锁对象有个计数器,会记录线程获取锁的次数,当执行完对应的代码后,计数器就会减去1,只有归零就会释放锁。之所以可以重入就是因为这个计数器。

5.synchronized和ReentrantLock的区别

可从锁的实现、功能特点、性能维度等分析

  • 锁的实现:synchronized是通过jvm实现,是java的关键字;而reentrantlock是通过jdk层面的api实现的的(一般是lock()和unlock()方法配合try/catch/finally语句实现)

  • 性能:jdk1.6前synchronized性能比较差,应该都是要通过底层调用,但是1.6以后增加了适应性自旋,锁消除等,两者性能差不多。

  • 功能特点:-

    • ReentrantLock比synchronized增加了一些高级功能,如等待中断,可实现公平锁,可实现选择性通知;
    • synchronized只能是非公平锁(内部锁),- ReentrantLock可以指定是公平还是非公平(公平锁就是先等待的线程先获得锁);
    • synchronized与wait()和notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock类借助Condition接口与newCondition()方法实现。
    • ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁

二、锁类型

锁可以分为

  • 悲观、乐观锁
  • 独享、共享锁
  • 互斥锁、读写锁
  • 可重入锁
  • 公平锁、非公平锁
  • 分段锁
  • 偏向锁,轻量级锁、重量级锁
  • 自旋锁

以上是锁的名词,有的是指锁的状态,有的是锁特性或者设计。

1.乐观锁、悲观锁

乐观锁和悲观锁并不是两种特定类型锁,是人们定义的概念或者思想。主要是指人们看待同步的角度。

  • 乐观锁:顾名思义就是乐观的认为每次取数据,别人都不会修改,所以不上锁,但是在更新的时候会去判断在此期间别人有没有取更新这个数据,可以使用版本号等机制,乐观锁适用于多读的应用程序,这样可以提高吞吐量,在java中原子变量类就是使用了乐观锁的一种实现方式CAS(compare and swap 比较并交换)来实现的
  • 悲观锁:总是假设每次去获取数据,都认为别人会修改,所以每次拿取数据都会进行上锁,这样别人拿取数据就会阻塞,直到拿到锁才行,比如Java里面的关键字synchronized实现就是悲观锁,悲观锁适合写操作多的场景

①. 乐观锁:乐观锁适合读多的场景,不加锁会代理大量的性能提升 ,在java编程中是无锁编程,常常采用的是CAS算法。典型的例子就是原子类,通过CAS自旋实现原子的更新操作。
乐观锁更新判断其他线程有没有更新共享变量 一般采用数据版本机制或者CAS操作实现
(1):数据版本机制:一般两种方式,一种是使用版本号,另一个是使用时间戳方式。
版本号方式:一般是在数据表上加上一个数据版本号version字段,表示更新的次数,当数据被更新的时候计数加一,当线程A更新数据时候,会在读取数据的同时也会读取version字段,在更新提交的时候,若刚才的读取的version和数据库中的version相等才会更新,否则会重新进行更新操作。直到更新成功。

update table set xxx=#{xxx}, version=version+1 where id=#{id} and version=#{version};

(2):CAS操作:当多个线程尝试使用CAS同时更新一个变量时候,只有一个线程能够更新变量,其他线程并不会被挂起,会收到通知失败,并可以再次尝试。
CAS需要三个字段值:1.需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B),如果内存位置的V值和预期原值A想匹配,那么就会更新B,否则不做变动。

②:悲观锁:悲观锁认为对于同一个数据的并发操作,一定会发生修改的,哪怕没有修改,也会认为修改。因此对于同一份数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁并发操作一定会出问题。在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。期间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

2.独享锁、共享锁

独享锁是指该锁只能被一个线程获取,共享锁是指该锁可以被多个线程持有。
对于java而言ReentrantLock是独享锁,但是对于另一个lock的实现类ReadWriterLock来说,其读是共享锁,其写是独占锁。独享锁和共享锁是通过AQS来实现的,通过不同的方法,来实现独享或者共享(synchronized是独占锁)

AQS:AbstractQueueSynchronized抽象同步队列,简称AQS;它是java并发包的基础,并发的锁就是基于Aqs实现的。

  • AQS是基于一个FIFO的双向队列,其内部定义了一个node节点类,node节点内部的SHARED用来标记该线程是获取共享变量时被阻挂起后放入AQS队列的,EXCLUSIVE用来标记线程是独占资源时被挂起放入AQS队列。

  • AQS使用一个volatile修饰的int类型的成员变量state来表示同步状态,修改同步状态成功表示获得锁,volatile保证了变量在线程之间的可见性,修改state通过CAS机制来保证修改的原子性。

  • 获取state方式有两种,独占和共享。一个线程使用了独占的方式,那么其他线程就失败会被阻塞;一个线程使用共享时获取资源,另一个线程还可以通过CAS的方式进行获取。

  • 如果共享资源被占用,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS会将获取共享资源失败的线程添加到一个变体的CLH中。

  • 线程基础知识(三)_第3张图片

    AQS中的ClH变体等待队列特性

  • AQs中队列是个双链表,也是符合FIFO先进先出的特性。

  • 通过head、tail两个头尾节点来组成队列结构,通过volatile来保证可见性。

  • Head指向的节点本身已经获得了锁,是一个虚拟节点,节点本身不具备具体线程

  • 获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后,会将线程阻塞,相对于CLH队列性能较好。

3.互斥锁/读写锁

  • 讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
  • 互斥锁在Java中的具体实现就是ReentrantLock。
  • 读写锁在Java中的具体实现就是ReadWriteLock:对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写。

4.可重入锁:可重入锁又名递归锁,是指在同一个线程在外层获锁的时候,在进入内存自动获取锁。也就是在执行对象中所有的同步方法不用再次获取锁。 对于Java ReetrantLock而言,从名字就可以看出是一个重入锁,其名字是Reentrant Lock 重新进入锁。对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

synchronized void setA() throws Exception{
  Thread.sleep(1000);
  setB();
}
 
synchronized void setB() throws Exception{
  Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点。如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。

4. 公平锁和非公平锁

  • 公平锁是指多个线程按照锁的申请顺序来获取锁,按等待时间来获取锁,等待时间长的线程有优先获取锁的权利。
  • 非公平锁就是不是获取锁的顺序不是按照申请锁的顺序,有可能后申请的先执行,有可能会造成优先级反转或者饥饿现象。
  • 对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大
  • 对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁

5.分段锁

分段锁是一种设计,并不是具体的一种锁,对于ConcurrentHashMap而言是最好的例子,其并发就是通过分段式锁来实现的
线程基础知识(三)_第4张图片

  • ConcurrentHashMap实现原理:内部分为了若干的小的hashmap,称为段(segment),默认情况下一个ConcurrenthashMap
    分为16段,即就是锁的并发度,如果需要在ConcurrenthashMap中添加key-value,并不是将整个都加锁,而是 首先根据hashcode计算出key-value应该存放在那个段中,然后对该段加锁,并完成put操作,在多线程操作中,如果多个线程进行put操作,只要被加入的key-value不在同一个段中,则线程就可以实现真正的并行。
  • 线程安全:ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全
  • 6.偏向锁/轻量锁/重量级锁

java每个对象都可以作为锁,锁有四种基本:无锁、偏向锁、轻量级锁、重量级锁,并且锁可以进行升级不能下降,这三种是指锁的状态,并且是针对synchronized的,是在java5,jdk1.6后引入实现高效升级synchronized,这三种锁通过对象监视器在对象头中的字段表明。

  • 偏向锁:是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
    在一段时间内,锁不存在多线程竞争, 而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁的概念。怎么理解偏向锁呢? 当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
  • 轻量级锁:是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
    -重量级锁:是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。

7.自旋锁

线程基础知识(三)_第5张图片
自旋锁是一种技术,是为了让线程等待,我们只需要让线程执行一个忙循环。自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式获取锁,这样好处是减少线程上下文切换的消耗,缺点是循环会消耗cpu
自旋锁是一种非阻塞锁,核心就是自旋两个字,即用自旋代替阻塞操作,某一个线程尝试获取锁的时候,如果该锁已经被另一个线程占用,那么这个这个线程将不断循环进行检查该锁是否被释放((默认次数是10,可以使用-XX:PreBlockSpinsh参数设置该值)),而不是让此线程挂起或者睡眠,一旦另一个线程释放锁那么此线程就会立即获得锁。自旋是一种忙等待状态,过程会一直消耗cpu的时间片。

8.可中断锁

在等待锁的过程中可以中断。

9.死锁

死锁是一种现象,程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。
死锁不能自行打破,所以线程死锁后,线程不能进行响应,所以要注意线程的使用并发场景。

死锁形成条件

  1. 互斥条件:指线程对已经获取到的资源进行排他性使用。
  2. 请求并持有:指一个线程已经持有了最少一个资源,但是有提出来新的资源请求,而新资源已经被其他线程给占用,所以当前线程会被阻塞,但阻塞的同时不会释放自己持有的资源。
  3. 不可剥夺条件:指线程获取到的资源在自己使用完成之前不能被其它线程抢占,只能是自己在使用完毕后由自己进行释放。
  4. 环路等待条件:指发生死锁的时候,必然形成了一个线程------资源的环形链。

如何破坏避免形成死锁呢:

  1. 条件1互斥条件肯定不能破坏,只能是下面三个条件进行破坏
  2. 请求并持有,我们可以一次性请求所有的数据。
  3. 对于不可剥夺条件,占用部分资源进一步申请其他资源的时候,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就失效了。
  4. 对于环路等待条件可以按顺序进行申请资源来预防。

如何排查
可以使用jdk自带的工具排查:

  • 1.使用jps查找运行的java进程:jsp -1
  • 2.使用jstack查看线程堆栈信息
  • 3.可以利用图形化工具Jconsole,出现死锁点击面板就能看见

三、 总结

线程基础知识(三)_第6张图片

你可能感兴趣的:(多线程,java知识,java,开发语言)