并发与锁知识

Posted 小智RE0

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发与锁知识相关的知识,希望对你有一定的参考价值。

最近在看大佬的知识讲解:
【计算机知识串讲】编程中的并发与锁


文章目录


CPU概述


CPU作为重要的计算机组件,由计算器与控制器组成,使用总线连接其他设备.

南桥主要用来连接一些带宽比较低的设备,比如鼠标,键盘等USB连接设备;
北桥主要用来连接带宽比较高的设备,比如CPU,RAM存储器,PCIE显卡…

CPU的常见参数:

架构:

  • X86 个人PC/服务器
  • ARM 智能手机…移动设备
  • MIPS 小型路由器

频率:

例如 5GHZ : 每秒钟运行5G次基础的指令运算

核心数,线程数:

每个CPU可集成多个CPU核心,可并行处理任务.
大多数情况下 核心数等于线程数,但也有超线程技术.

Cache

CPU缓存


Linux系统下查看CPU状态的常用指令

可以在proc/cpuinfo 这个文件目录下查看;

cat  /proc/cpuinfo 

可以使用top指令查看CPU的运行情况,内存的使用情况

top

vmstat命令可以查看虚拟机系统的各种参数;

vmstat

可用uptime命令;可以用来查看服务器已经运行了多久,CPU的运行情况.

uptime

进程,线程,协程


进程是计算机进行资源文件管理,调度,分配的最小单位,而线程是CPU的最小执行单位.

比如说Node.js是单线程的,但是他也可以应对高并发的场景,但它的IO是异步处理的

线程在执行时会有一些性能损耗,这些性能损耗主要来自于线程的创建,销毁和切换;
线程本身在使用时就需要向CPU申请资源,而申请内核态会进入到内核态,由内核态帮助完成操作.
对于线程的创建和销毁来说,可使用线程池来完成,
线程的切换可使用协程来完成操作;

协程作为一种用户自定义的线程,不涉及用户态与内核态的转化;

上面第一种直接进行线程的上下文切换,会产生一定的开销;

下面采用协程完成切换操作,减少了线程切换的开销;


synchronized


关于synchronized
小知识点记录:同步与锁的基础知识之前有记录;

首先看这样一段demo;

public class Demo 
    public static void main(String[] args) 
        //触发时间;
        long end = 10000;
        long[] x = new long[1];
        for (int i = 0; i < end; i++) 
            new Thread(()->x[0]++;).start();
        
        System.out.println(x[0]);
    

运行后并没有达到预期的值10000;

那么实际上这里 x[0]++ 的操作也就是分为了三步:
先获取到x[0]的值,然后加1处理,然后再将值重新赋值给x[0];


在jdk1.6之前锁的方式都是以重量级锁的状态存在; 这里就需要涉及到用户态与内核态的切换.

操作锁的方法在C语言内部都有封装使用到:


那么对于重量级锁如何优化呢?
可以 定义一个state状态,使用0,1 来区分不同的状态,比如 : 0表示资源未被使用, 1表示资源正在使用.

可编写这样一段伪代码:

//上锁;
void lock()
     while(state == 1);
     state = 1;
     持有者 = 当前线程;

//开锁;
void unlock()
     if(持有者 = 当前线程)
        state = 0;
        持有者 = null;
     else
        throw new Exception();
     

初步来看这样的设计还算比较合适,但是获取不到资源的线程会一直阻塞产生消耗;


尝试采用CAS(比较并且交换)的机制;进行优化;

这样采用while循环和CAS机制 也就是常说的自旋锁,避免了线程进入内核态;整个操作都在用户态进行;
但这种方式也是有消耗的,因为一直自旋处理,也会对cpu产生消耗.

//上锁;
void lock()
     while(!CAS(state,0,1));
     持有者 = 当前线程;

//开锁;
void unlock()
     if(持有者 = 当前线程)
        state = 0;
        持有者 = null;
     else
        throw new Exception();
     

在jdk1.4之后又对自旋锁进行了优化,即可以设定自旋的次数, 当自旋进行了一定的次数还没有获取到资源时就放弃自旋, 进入到 阻塞状态;
适应性自旋锁.


锁膨胀


这部分知识,先看对象, 对象基本由对象头对象数据内容组成;
这部分在学习内存知识中有接触过;

在对象头中,普通对象一般包含markword与klassword,而数组对象还会具有ArrayLength;

其中在mark Word中会记录到GC分代年龄,锁状态的信息…

比如这样的分布图;

锁标志位判断: 00:轻量级锁,10重量级锁 ; 11 GC标志;01:无锁/偏向锁;

对于无锁和偏向锁的判断,在偏向锁中会记录线程的Id,

轻量级锁中有指向栈中锁记录lockRecord的指针;
重量级锁中有指向互斥量monitor的指针


大佬分享的源码链接:
synchronized的示例代码: https://github.com/sunwu51/notebook/blob/master/20.06/A.java


拉取到代码A之后;
可以在pom.xml中添加依赖;
但是不建议使用jol库;

<dependency>
     <groupId>org.openjdk.jol</groupId>
     <artifactId>jol-core</artifactId>
     <version>0.9</version>
</dependency>


首先运行测试方法1;正常创建的对象,状态无锁


可看到对象的默认hashcode为0;当第一次计算后才会将hashcode的值存入; 可知hashcode是惰性的.

接着模拟gc回收,注意gc不一定会立即触发;
这次模拟gc执行后,对象的年龄增加了1岁;


执行测试方法2;正常创建的对象,状态无锁,要是直接加锁就会变为轻量级锁.


执行测试方法3;
当我们的程序启动一段时间后,正常创建的对象,状态就变为偏向锁, 并且此时的thread为0;

-XX:BiasedLockingStartupDelay=0可以指定默认就使用偏向锁,而不是无锁


执行测试方法4;
在测试方法3的基础上:当我们的程序启动一段时间后,正常创建的对象,状态就变为偏向锁, 并且此时的thread为0; 那么此时当另一条线程尝试获取对象锁时,此时锁升级为轻量级锁.


执行测试方法5;
在测试方法4的基础上,当出现竞争时,就升级为重量级锁


执行测试方法6,模拟锁竞争的过程;


具体看锁的工作原理;
比如这个偏向锁加锁:

首先这个线程的id获取到之后CAS判断状态,

cas(thread, 0 , 当前线程);

状态为0直接获取到锁对象;
当状态为1时:
(1)若为可重入,则在栈上创建一个lockRecord锁记录;指向它;
注意lockRecord由两部分区域:M区:markword,O区:存放指针.
(2)那么就说明有其他的线程已经持有当前对象锁;那么此时需要升级为轻量级锁;

偏向锁的解锁过程:

只需要删除在栈上最近的一个O区中指向当前对象的lockRecord锁记录;

轻量级锁:

也是CAS判断,注意判断的是指向锁记录lockRecord的指针;

cas(指针, 0 , 当前创建的lockRecord);

状态为0直接获取到锁对象;
状态为1则:
(1)先找当前线程对应的栈,是否可找到对应的指针,若是则为可重入;
(2)说明有其他线程获取锁,那么就得升级为重量级锁;


ReentrantLock


ReentrantLock 锁 主要是基于AQS, 抽象同步队列;

  • 基于CAS机制,获取到对象锁,将对象锁的持有者变为自己;
  • 若获取不到对象锁,使用方法tryAqire()方法尝试获取,若获取到则将对象锁的持有者变为自己;若没有获取到则判断是否为可重入状态;
  • 若可重入则使用即可,若不是可重入 则使用addWaiter()方法,也就是将当前的线程作为一个node节点存入到双向队列的尾部, 当存入队列后,这个node节点会被返回,触发acquireQueued() 方法,即队列中的节点请求获取对象锁;会判断是否为队列中的头结点;[注意 之前所说的队列head和tail都是空的,这里的头结点指的就是head节点的下一个节点];
  • 若是头结点,则可使用tryAqire()方法尝试获取对象锁; 注意要是tryAqire()方法失败的话,就走线程park状态;
  • 若不是头结点,则判断线程的标志位,但是第一次标志位没有修改,那么就再次循环===>还是先判断是否为队列的头节点;(…),若不是队列的头结点;则可以让当前线程进入到park状态[即线程让出当前CPU的资源];即发生阻塞;
  • 注意一个问题:要是在队列中的线程已经被park阻塞了,上一个线程执行完毕时,这时park状态的线程正要被触发unpark解除阻塞状态,此时又new来了一个新的线程,那么他们就会有竞争关系,而 之前阻塞状态的线程解除阻塞状态的这个过程还是比较消耗的…,竞争起来有一定的压力.
  • 那么实际上ReentrantLock是有一种公平锁状态的,它就不存在新来的线程和队列中解除阻塞状态的线程 进行竞争,而是会直接将新来的线程存入到队列的尾部.

大佬分享的源码链接:
用到的lock的示例代码: https://github.com/sunwu51/notebook/blob/master/20.06/B.java


测试方法1进行debug调试;

 public static void test1() throws InterruptedException 
		   Lock l = new ReentrantLock();
		   Thread t1 = new Thread(()->
		       l.lock();
		       System.out.println("线程" + Thread.currentThread().getId() +"拿到锁");
		       try 
		           Thread.sleep(100000000000000000l);
		        catch (InterruptedException e) 
		           System.out.println("线程" + Thread.currentThread().getId() +"释放锁");
		       
		       l.unlock();
		   );
		   t1.start();
		   Scanner sc = new Scanner(System.in);
		   sc.nextLine();
		   new Thread(()->
		       l.lock();
		       System.out.println("线程" + Thread.currentThread().getId() +"拿到锁");
		       l.unlock();
		   ).start();
		
		   sc.nextLine();
		   t1.interrupt();

就会进入到加锁方法lock();
其中有方法setExclusiveOwnerThread将当前线程作为对象锁的持有者;

紧接着线程进入休眠状态;

控制台输入一段内容,回车;继续debug.

那么此时这个线程过来就得acquire()尝试;

可看到acquire()方法的内部定义; 有tryAcquire() 方法 以及 acquireQueued方法

tryAcquire() 方法的内部,实际是非公平锁的tryAcquire ;即nonfairTryAcquire()方法

然后是addWaiter方法将线程封装为Node节点, 放入到队列的尾部.

然后是acquireQueued 方法;
这里会看到一个for循环;


接下来在控制台输入一行文字后,回车, 第一个线程就会释放对象锁;

释放方法使用unlock()

那么实际上释放时会使用到方法 release();

注意方法unparkSuccessor() 来唤醒一个节点;
最终采用unpark方法唤醒节点中封装的线程; 解除了阻塞

后面第二个线程被唤醒了,那么就会在队列的节点中开始判断处理…


接着来看测试方法2

创建线程,在持有锁的状态下,休眠5秒钟;然后循环100次进行加锁释放锁操作,每个线程在操作后会休眠10毫秒;那么也就保证了 第一个线程休眠5秒钟时,其他的100个线程就会park阻塞状态,按次序地排入到等待的队列中.

public static void test2() throws InterruptedException 
   Lock l = new ReentrantLock();
   new Thread(()->
       l.lock();
       try 
           Thread.sleep(5000l);
        catch (InterruptedException e) 
           e.printStackTrace();
       
       l.unlock();
   ).start();
   for(int i=0;i<100;i++)
       int t = i;
       new Thread(()->
           l.lock();
           System.out.println(t);
           l.unlock();
       ).start();
       Thread.sleep(10);
   


关于锁的总结:


以上是关于并发与锁知识的主要内容,如果未能解决你的问题,请参考以下文章

面试官必问java 并发知识总结-同步与锁

面试官必问java 并发知识总结-同步与锁

SQL- 基础知识梳理 - 事务与锁

线程安全与锁优化

mysql的存储引擎与锁

jvm(13)-线程安全与锁优化(转)