线程与锁深入浅出

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


一、线程的介绍

  1. 线程:是独立执行的路径,每个CPU只能执行一个线程,只是每个线程执行的时间不同,所以是逻辑实现多线程。多个CPU执行不同的线程才是真正的多线程。
  2. 线程执行:是由cpu去执行,对同一资源会出现资源抢夺的问题,需要加入并发控制。

    例:一万个人来抢1000张票加并发,只有1000个人能抢到,数据库只能减少到0,如果不加并发控制10000个人都能抢到票,10000个人都能抢到,这是错误的,并且数据库会出现-9000。

  3. 线程消耗:让线程排队会增加开销。
  4. java项目启动默认的线程:main线程(用户线程)、GC线程(守护线程)

二、线程创建

  1. 继承Thread类,其实此类就是实现了Runnable接口。

    注意:线程开启不一定立即执行,由CPU执行。

  2. 实现Runnable(最重要)接口创建线程,java单继承推荐使用Runnable接口。

    优点:一个对象可以用在多个线程里面。
    启动方法:new Thread([Runnable实例]).start();

  3. 使用Callable(工作多年后核心)和Future创建线程。
  4. 使用线程池例如用Executor框架。

三、线程注意点:

3.1 CAS(compare and swap)比较和交换

在没有锁的情况下保证当前值得线程安全。

3.2.1 实现过程:

先比较需要修改的值是否被改变,如果改变就获取改变的值与参数累加,在写入的时候再比较是否有改变,总之每一次改变都需要先比较,直到找到没有改变的值就存入。

3.2.2 cas应用:

    AtomicInteger:底层使用了cas进行处理。
    Synchronized中的自旋锁。

3.2.3 aba问题:

  1. 有多个线程,1线程拿到个值是0,2线程改为了2然后做了某些处理后又变为了0,然后1线程拿到的还是0。
  2. 解决:把当前值加一个版本号当任何一个线程改变后同时也修改版本号,在比较的时候不仅比较这个值,同时也要加版本号。jdk中解决办法就是使用AtomicStampedReference类,这个类每次修改会记录修改的时间戳,当修改时不仅修改值还会判断这个时间戳,当时间戳一致时这个值才会被写入。
  3. cas底层实现:在底层使用了一条命令: lock copxchg,lock保证了即使是在修改或者比较的哪一瞬间,其他的线程不允许打断此线程的执行。

四、线程中数据同步

4.1 synchronized

4.1.1 原理(实际执行的命令:lock cmpxchg)

从字节码层面的源码:从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

  1. 从锁的层面:锁升级(jvm执行的过程中进行升级)
  2. cpu汇编层面: lock cmpxchg

4.1.2 JOL(Java Object Layout)查看对象布局工具

4.1.2.1 使用方法
  1. 引用插件
<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.16</version>
</dependency>
  1. 创建一个对象,使用ClassLayOut调用:ClassLayout.parseInstance(obj)
    在这里插入图片描述
    在这里插入图片描述

普通对象布局:
mark对象头(64位 8子节):记录锁信息(锁状态、锁的类型、偏 轻 重锁id)、GC分代年龄、哈希码。
class Point对象头(32位 4字节):存放的是类之前的引用(指针)[java是64位,那么他的指针就是64位,把个字节因为在启动虚拟机的时候开启了压缩指针,所以变为了4个字节]。
instanceData实例数据:存放对象实例的数据,成员变量存放的地方
padding:对齐

4.2 volatile关键字

4.2.1 volatile两大特性

  1. 线程可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  2. 指令指针重排序:即程序执行的顺序按照代码的先后顺序执行。一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,它会保证程序最终执行结果和代码顺序执行的结果是一致的(保正结果一致的原理是:判断数据之间的依赖,如果最后执行需要依赖前一个数据那么就会按照依赖顺序执行)。

4.2.2 注意

  1. 无法保证非原子操作的原子性。
  2. 修改数据后能保证数据的可见性、但不能保证非原子操作的原子性(原子操作:i=10。非原子操作:i++;前者是只做值赋操作。而后者需要先拿出原始的i,然后再把i加1,再赋值给原来的值,以上有三步操作。如果在多线程的情况下,其中某一步骤还未执行完线程被阻塞就会出现数据不一致的情况。)

4.2.3 原理

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。
  4. 在不使用volatile关键字的情况下,有哪些情况会导致线程的工作内存失效,然后必须重新去读取主存的共享变量?
    a. 线程中释放锁时
    b. 线程切换时
    c. CPU有空闲时间时(比如线程休眠时)

4.2 锁升级

对象加锁升级的过程:

无锁 -> 偏向锁 -> 轻量锁(自旋锁->无锁)-> 重量级锁

  1. 无锁状态(刚new出来的对象) ->
  2. 偏向锁(如果第一个线程过来,发现这个对象之前没有被使用过就给这个对象头中贴个第一个进来线程的标签[在整个markWord里面用54位记录了指向当前线程local record(54bit)的指针],当相同线程进来对象发现还是这个线程就直接进入无需加锁))->
  3. 轻量锁(有多个线程竞争,其他线程过来发现此对象已经被占用,就撤销偏向锁状态。每个线程有一个lock record,然后多个线程进行抢占,主要是为了将当前对象的指针指向抢占成功线程的lock record,抢占的过程就是线程在不断的进行CAS操作【拿出加锁标记来与当前线程的标记比较,如果一致就表示抢占成功】,所以也称为自旋锁,jdk1.6 以后出来了一个自适应(自动设置自旋的次数)自旋锁)->
  4. 重量级锁(竞争频繁,有的线程自旋超过10次,或者cpu占用超过50%,就会将当前获取轻量级锁的等级通过内核态去操作升级为重量级锁,当获取到重量级锁,mark Word中就会指向重量级锁的指针,并且将其他的线程阻塞挂起,等待当前线程执行完毕在唤醒其他的线程,主要使用操作系统的互斥量)。升级重量级锁的好处,每一个重量级锁都有一个队列,当锁申请成功了实际就是进入了这个重量级锁的队列,如果没有轮到下一线程执行的时候,是不用消耗任何内存的,因为队列中的线程是wait状态,重量级锁消耗比较高,是因为需要调用内核态的方法(重量级锁:用户态->内核态->用户态)。

4.3 锁降级

锁降级指的是写锁降级为读锁的过程,他的过程是持有写锁,获取读锁,然后释放写锁。出现情况,在GC的时候,获取写锁,但是此时所有的线程都被阻塞了,所以就算将此写锁降为读锁也是无意义的。

4.4 锁消除

例如使用StringBeffer 进行追加的时候,发现多个append都是在同一个方法中,因为一个方法会被一个线程加入到私有栈中,所以加锁与不加锁的没有太大的区别,就直接把锁拿走,这就叫锁消除。

4.5 锁粗化

JVM会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

五、JMM(Java Memory Model)线程内存模型

  1. ***read(读取)***:作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  2. ***load(载入)***:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  3. ***use(使用)***:作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  4. ***assign(赋值)***:作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  5. ***store(存储)***:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  6. ***write(写入)***:作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

六、CPU与缓存关系

在这里插入图片描述
CPU缓存(3层)【cpu读的顺序:1 -> 2 -> 3;写的顺序:3 -> 2 -> 1。读的顺序是按块读】:

  1. L1 Cache(一级缓存):CPU第一层高速缓存,它都是分数据缓存和指令缓存两块。从上面大家也了解了,CPU在L1 Cache中有80%的命中率 ,所以,L1高速缓存的容量大小对CPU的性能影响不明显。所以,为了控制成本和体积,它的容量不用很大,很容易集成在CPU里面。
  2. L2 Cache(二级缓存):CPU的第二层高速缓存,分内部和外部两种接口。内部接口的二级缓存运行速度与主频相同,而外部接口二级缓存运行速度则只有主频的一半。L2高速缓存容量大小对CPU的性能影响非常大, 在CPU核心不变化的情况下,增加二级缓存容量能使性能大幅度提高。但是,由于二级缓存容量是由CPU制造工艺决定的,容量增大又让CPU内部晶体管数增加,而要在有限的CPU面积上集成更大的缓存,对制造工艺的要求也就越高。
  3. L3 Cache(三级缓存):CPU的第三层高速缓存容量更大,运行速度则更慢,它和内存链路直接连接,可以进一步降低内存延迟,同时提升大数据量计算
    时处理器的性能。所有的CPU核心,都共享同一个 L3 Cache。

缓存行(Cache Line)读的顺序是按块来读,这个块就是缓存行,一行数据长度是64 byte(1byte[字节] = 8bit[位])
在这里插入图片描述

  1. cpu层级的数据一致性是以缓存行为单位,一个缓存行数据被修改就会通知其他的CPU进行相应的修改操作。
  2. 缓存行对齐:对于有些特别敏感的数字,会存在线程高竞争的访问,为了保证不发生伪共享,可是使用缓存行对齐的编程方式。
disruptor:
public long p1,p2,p3,p4,p5,p6,p7 //cathe padding 
private volatile cursor = INITIAL_CURSOR_VALUE;
public long p8,p9,p10,p11,p12,p13,p14 //cathe padding
// 以上方式只针对intel CPU 生效.
// 所以用下面方式对于任何CPU都生效,另外JDK8中 注解 @Contended 注解可以保证不和别的在同一缓存行。 
// 需要加上:JVM -XX:-RestrictContended。 JDK7中,很多采用long padding提高效率。
  1. 缓存对齐应用框架 Disruptor,全世界单机最快的单机队列。底层:环形队列 + 缓存对齐。

七、进程与线程的区别

线程是CPU执行的基本单元、进程是CPU分配资源的基本单元。

八、超线程

  1. 优点:
    超线程技术能够同时执行两个线程。
  2. 缺点:
    a. 当两个线程同时需要某个资源时,其中一个线程必须让出资源暂时挂起,直到这些资源空闲以后才能继续。因此,超线程的性能并不等于两个CPU的性能。
    b. 一个CPU中有两套处理线程的硬件,两套程序计数器

九、线程用到的方法

sleep(): 线程睡眠会阻塞当前执行线程。
join():当前线程执行结束时会执行的方法,当主线程中调用了这个方法,主线程会被阻塞,知道当前子线程执行结束。
start():启动线程,会去执行线程中的run()方法。

十、线程池

创建线程参数:
corePoolSize:核心线程数量
maximumPoolSize:最大线程数量
keepAliveTime:活跃的时间
TimeUnit:时间类型
workQueue: 阻塞线程

  1. 每当我们调用execute()方法添加一个任务时,线程池会做如下判断:·如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;
  2. 如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列;
  3. 如果这时候队列满了,而且正在运行的线程数量小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;·如果队列满了,而且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会抛出异常RejectExecutionException。
  4. 当一个线程完成任务时,它会从队列中取下一个任务来执行。当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断。
  5. 如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。

提示:这里可以添加本文要记录的大概内容:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值