Java-并发编程

并发编程

并发这一块的东西,自己之前都是断断续续的一边学一下,一边用一下,都没有很系统的整理一下,所以今天他来了。


NIO

https://vgbhfive.cn/Java-NIO/


多线程

synchronized 关键字

synchronized 是Java 的关键字,是Java 的内置特性,在JVM 层实现了对临界资源的同步互斥访问,并且通过锁机制实现同步,但synchronized 粒度较大,在处理问题时有很多的局限性,比如响应中断。

具体表现

  • 对于普通方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class 对象。
  • 对于同步方法块,锁是Synchronized 括号里的配置对象。

当一个线程试图访问同步代码块时,他必须获得锁,在抛出异常或者退出时必须释放锁。

占有锁的线程释放锁

  • 占有锁的线程执行完了该代码块,然后释放对锁的占有。
  • 占有锁的线程发生异常,此时JVM 会自动释放锁。
  • 占有线程进入WAITING 状态时,从而释放锁。

Synchronized 实现原理

Synchronized 的语义底层是通过一个monitor 的对象来完成。
JVM 中对于monitor 对象的描述:

1
2
3
4
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.

翻译过来就是,每个对象有一个监视器锁(monitor) 。当monitor 被占用时就会处于锁定状态,线程执行monitorenter 指令时尝试获取monitor 的所有权,过程如下:
1、如果monitor 的进入数为0,则该线程进入monitor ,然后将进入数设置为1 ,该线程即为monitor 的所有者。
2、如果线程已经占有该monitor ,只是重新进入,则进入monitor 的进入数加1.
3.如果其他线程已经占用了monitor ,则该线程进入阻塞状态,直到monitor 的进入数为0,再重新尝试获取monitor 的所有权。

Synchronized 与Lock 比较

  • Lock 是一个接口,是JDK 层面的实现,并且Lock 提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有的加锁和解锁的方法都是显式的。
  • synchronized 在发生异常时,会自动释放锁,而Lock 在发生异常时没有释放锁,则会造成死锁现象,因此使用Lock 需要在finally 中释放锁。
  • Lock 可以让在等待锁的线程响应中断,而synchronized 不行,会一直等待下去,不能响应中断。
  • 通过Lock 可以获知有没有成功获取锁,而synchronized 不行。
  • Lock 可以提高多个线程进行读操作的效率。

总结

从性能上说,如果竞争资源不激烈,两者的性能是差不多的。而当竞争激烈时(即大量线程同时竞争) ,此时Lock 的性能远远要比synchronized 强。

volatile 关键字

一个共享变量(类的成员变量、类的静态成员变量) 被volatile修饰之后,那么就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 禁止进行指令重排序。

示例

1
2
3
4
5
6
7
8
//线程1
boolean stop = false;
while(!stop){
doSomething();
}

//线程2
stop = true;

在这段代码中,能够很清楚的明白,线程1 会一直循环下去。这是因为线程1 在启动之时复制了一份stop 的变量到自己的工作内存当中,此时线程2 更新了stop 的值但是线程1 却又无从得知,就造成了线程1 一直循环下去。

使用volatile 关键字之后就不一样了:

  • 使用volatile 关键字会强制将修改的值立即写入主存。
  • 使用volatile 关键字的话,当线程2 进行修改时,会导致线程1 的工作内存中缓存变量stop 的缓存行无效(反映到硬件层的话,就是CPU 的L1 或者L2 缓存中对应的缓存行无效)
  • 由于线程1 的工作内存中缓存变量stop 的缓存行无效,所以线程1 再次读取变量stop 的值时会去主存读取。

那么在线程2 修改stop 值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存) ,会使得线程1 的工作内存中缓存变量stop 的缓存行无效,然后线程1 读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。所以线程1 读取到的就是最新的正确的值。

volatile 原理及实现机制

下面这段话摘自《深入理解Java虚拟机》
“观察加入volatile 关键字和没有加入volatile 关键字时所生成的汇编代码发现,加入volatile 关键字时,会多出一个lock 前缀指令”

lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏) ,内存屏障会提供3个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
  • 它会强制将对缓存的修改操作立即写入主存。
  • 如果是写操作,它会导致其他CPU 中对应的缓存行无效。

volatile 的疑问

  1. 如何保证原子性
    在java 1.5 的java.util.concurrent.atomic 包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作) ,自减(减1操作) 、以及加法操作(加一个数) ,减法操作(减一个数) 进行了封装,保证这些操作是原子性操作。
    atomic 是利用CAS 来实现原子性操作的(Compare And Swap ) ,CAS 实际上是利用处理器提供的CMPXCHG 指令实现的,而处理器执行CMPXCHG 指令是一个原子性操作。

  2. 如何保证有序性
    volatile 关键字禁止指令重排序有两层意思:

  • 当程序执行到volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将在对volatile 变量访问的语句放在其后面执行,也不能把volatile 变量后面的语句放到其前面执行。

使用场景

synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile 关键字在某些情况下性能要优于synchronized ,但是要注意volatile 关键字是无法替代synchronized 关键字的,因为volatile 关键字无法保证操作的原子性。

简单来说就是需要保证操作是原子性操作,这样才能保证使用volatile 关键字的程序在并发时能够正确执行。

内存屏障

JVM内存屏障:(Memory Barrier),是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题,Java编译器也会根据内存屏障的规则禁止重排序。

硬件共有以下类型:

  • Load 屏障,是x86上的”ifence” 指令,在其他指令前插入ifence 指令,可以让高速缓存中的数据失效,强制当前线程从主内存里面加载数据
  • Store 屏障,是x86的”sfence” 指令,在其他指令后插入sfence 指令,能让当前线程写入高速缓存中的最新数据更新写入主内存,让其他线程可见。

JVM 共有以下几种类型:

  • LoadLoad屏障。
  • StoreStore屏障。
  • LoadStore屏障。
  • StoreLoad屏障。

从上述就能看出来,其实就是上面硬件屏障的两两组合。

volatile 内存屏障

在每个volatile 写操作前插入StoreStore 屏障,这样就能让其他线程修改A 变量后,把修改的值对当前线程可见,在写操作后插入StoreLoad 屏障,这样就能让其他线程获取A 变量的时候,能够获取到已经被当前线程修改的值。
在每个volatile 读操作前插入LoadLoad 屏障,这样就能让当前线程获取A变量的时候,保证其他线程也都能获取到相同的值,这样所有的线程读取的数据就一样的,在读操作后插入LoadStore 屏障;这样就能让当前线程在其他线程修改A变量的值之前,获取到主内存里面A变量的的值。

公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized 而言,也是一种非公平锁。由于其并不像ReentrantLock 是通过AQS 的来实现线程调度,所以并没有任何办法使其变成公平锁。

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的示例。

1
2
3
4
5
6
7
8
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}

synchronized void setB() throws Exception{
Thread.sleep(1000);
}

对于Java ReentrantLock 而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock 重新进入锁。
对于Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock 而言,其是独享锁。但是对于Lock 的另一个实现类ReadWriteLock ,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的,读写、写读、写写的过程是互斥的。
独享锁与共享锁也是通过AQS 来实现的,通过实现不同的方法,来实现独享或者共享。
对于Synchronized 而言,当然是独享锁。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
互斥锁在Java 中的具体实现就是ReentrantLock 。
读写锁在Java 中的具体实现就是ReadWriteLock 。

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
悲观锁在Java 中的使用,就是利用各种锁。
乐观锁在Java 中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS 自旋实现原子操作的更新。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

我们以ConcurrentHashMap 来说一下分段锁的含义以及设计思想,ConcurrentHashMap 中的分段锁称为Segment ,它即类似于HashMap (JDK7与JDK8中HashMap 的实现) 的结构,即内部拥有一个Entry 数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment 继承了ReentrantLock )。
当需要put 元素的时候,并不是对整个hashmap 进行加锁,而是先通过hashcode 来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put 的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size 的时候,可就是获取hashmap 全局信息的时候,就需要获取所有的分段锁才能统计。

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

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized 。在Java 5通过引入锁升级的机制来实现高效Synchronized 。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

自旋锁

在Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU 。

线程池

java.uitl.concurrent.ThreadPoolExecutor 类是线程池中最核心的一个类,详细了解并使用以下这个类就基本上可以了。

推荐这篇博客

线程、进程间通信

进程是具有一定独立功能的程序、它是系统进行资源分配和调度的一个独立单位,重点在系统调度和单独的单位,也就是说进程是可以独立运行的一段程序。
线程是进程的一个实体,是CPU 调度和分派的基本单位,他是比进程更小的能独立运行的基本单位,线程自己基本上不拥有系统资源。在运行时,只是暂用一些计数器、寄存器和栈。

进程、线程之间的关系

  • 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程(通常说的主线程)
  • 资源分配给进程,同一进程的所有线程共享该进程的所有资源。
  • 线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。
  • 处理机分给线程,即真正在处理机上运行的是线程。
  • 线程是指进程内的一个执行单元,也是进程内的可调度实体。

区别:

  • 调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位。
  • 并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可以并发执行。
  • 拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源。

多线程间通信方式

  • 共享变量 (volatile)
  • wait/notify 机制 (synchronized)
  • Lock/Condition 机制 (Lock)
  • 管道流 (pip 流)

进程间通信方式

  • 管道(Pipe)
  • 命名管道(named pipe)
  • 信号(Signal)
  • 消息(Message) 队列
  • 共享内存
  • 内存映射(mapped memory)
  • 信号量(semaphore)
  • 套接口(Socket)

并发


实战

Vert.x

https://github.com/vgbhfive/VertxDemo

100W TPS 秒杀系统

秒杀场景一般会在电商网站举行一些活动或者节假日在12306网站上抢票时遇到。对于电商网站中一些稀缺或者特价商品,电商网站一般会在约定时间点对其进行限量销售,因为这些商品的特殊性,会吸引大量用户前来抢购,并且会在约定的时间点同时在秒杀页面进行抢购。

设计理念

  • 限流: 鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端。
  • 削峰: 对于秒杀系统瞬时会有大量用户涌入,所以在抢购一开始会有很高的瞬间峰值。高峰值流量是压垮系统很重要的原因,所以如何把瞬间的高流量变成一段时间平稳的流量也是设计秒杀系统很重要的思路。实现削峰的常用的方法有利用缓存和消息中间件等技术。
  • 异步处理: 秒杀系统是一个高并发系统,采用异步处理模式可以极大地提高系统并发量,其实异步处理就是削峰的一种实现方式。
  • 内存缓存: 秒杀系统最大的瓶颈一般都是数据库读写,由于数据库读写属于磁盘IO,性能很低,如果能够把部分数据或业务逻辑转移到内存缓存,效率会有极大地提升。
  • 可拓展: 当然如果我们想支持更多用户,更大的并发,最好就将系统设计成弹性可拓展的,如果流量来了,拓展机器就好了。像淘宝、京东等双十一活动时会增加大量机器应对交易高峰。

设计思路

前端:

  • 页面静态化:将活动页面上的所有可以静态的元素全部静态化,并尽量减少动态元素。通过CDN来抗峰值。
  • 禁止重复提交:用户提交之后按钮置灰,禁止重复提交。
  • 用户限流:在某一时间段内只允许用户提交一次请求,比如可以采取IP限流。

后端:

  • 限制uid(UserID)访问频率:我们上面拦截了浏览器访问的请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。
  • 服务层
    采用消息队列缓存请求:既然服务层知道库存只有100台手机,那完全没有必要把100W个请求都传递到数据库啊,那么可以先把这些请求都写到消息队列缓存一下,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。
    利用缓存应对读请求:对类似于12306等购票业务,是典型的读多写少业务,大部分请求是查询请求,所以可以利用缓存分担数据库压力。
  • 利用缓存应对写请求:缓存也是可以应对写请求的,比如我们就可以把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
  • 数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧。

案例

Redis 是一个分布式缓存系统,支持多种数据结构,我们可以利用Redis 轻松实现一个强大的秒杀系统。

我们可以采用Redis 最简单的key-value 数据结构,用一个原子类型的变量值(AtomicInteger )作为key,把用户id 作为value ,库存数量便是原子变量的最大值。对于每个用户的秒杀,我们使用 RPUSH key value 插入秒杀请求, 当插入的秒杀请求数达到上限时,停止所有后续插入。

然后我们可以在台启动多个工作线程,使用 LPOP key 读取秒杀成功者的用户id ,然后再操作数据库做最终的下订单减库存操作。

当然,上面Redis 也可以替换成消息中间件如ActiveMQ、RabbitMQ 等,也可以将缓存和消息中间件组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。


引用

https://www.cnblogs.com/dolphin0520/p/3920373.html


总结

这一块的东西感觉怎么说呢,还是需要多动手的,毕竟代码是写出来的,不是说出来的。


个人备注

此博客内容均为作者学习所做笔记,侵删!
若转作其他用途,请注明来源!