美文网首页
Java高并发系列——检视阅读(一)

Java高并发系列——检视阅读(一)

作者: 卡斯特梅的雨伞 | 来源:发表于2020-09-11 16:53 被阅读0次

Java高并发系列——基本概念

并发概念词

同步(Synchronous)和异步(Asynchronous)

同步和异步通常来形容一次方法调用,同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作

image.png

举例

拿泡泡面来说,我们把整个泡泡面的过程分3个步骤:

  1. 烧水
  2. 泡面加调味加蛋
  3. 倒开水泡面

如果我们泡泡面的时候是按照这三个步骤,等开水开了再加调味加蛋,最后倒开水泡面,这时候就是同步步骤;而如果我们烧开水的同时把泡面加调味加蛋准备好,就可以省去烧水的同步等待时间,这就是异步。

并发(Concurrency)和并行(Parallelism)

并发和并行是两个非常容易被混淆的概念。他们都可以表示两个或者多个任务一起执行,但是侧重点有所不同并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的(等待阻塞等),而并行是真正意义上的“同时执行” 。

举例:

大家排队在一个咖啡机上接咖啡,交替执行,是并发;两台咖啡机上面接咖啡,是并行。

并发说的是在一个时间段内,多件事情在这个时间段内交替执行

并行说的是多件事情在同一个时刻同时发生。

如果系统内只有一个CPU,而使用多进程或者多线程任务,那么真实环境中这些任务不可能是真实并行的,毕竟一个CPU一次只能执行一条指令,在这种情况下多进程或者多线程就是并发的,而不是并行的(操作系统会不停地切换多任务) 。

image.png

临界区

临界区用来表示一种公共资源或者说共享数据,可以被多个线程使用,但是每一次只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源就必须等待。

阻塞(Blocking)和非阻塞(Non-Blocking)

阻塞和非阻塞通常用来形容很多线程间的相互影响。比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中等待。等待会导致线程挂起,这种情况就是阻塞。 非阻塞的意思与之相反,它强调没有一个线程可以妨碍其他线程执行,所有的线程都会尝试不断向前执行。

死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)

死锁饥饿活锁都属于多线程的活跃性问题 。

死锁:两个线程都持有独占的资源(锁),同时又互相尝试获取对方独占的资源(锁),这时候双方都没有释放自己的独占资源,导致永远也获取不到阻塞等待下去。

饥饿是指某一个或者多个线程因为种种原因无法获得所要的资源,导致一直无法执行。一种比如它的优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作。另一种如某一个线程一直占着关键资源不放(例子:单线程池里submit一个线程任务,而该线程又往该单线程池里submit一个新的任务并等待结果返回,因为线程池是单线程池,所以便一直套娃着),导致其他需要这个资源的线程无法正常执行,这种情况也是饥饿的一种。与死锁相比,饥饿还是有可能在未来一段时间内解决的(比如,高优先级的线程已经完成任务,不再疯狂执行)。

活锁当两个线程都秉承着“谦让”的原则(导致死循环),主动将资源释放给他人使用,那么就会导致资源不断地在两个线程间跳动,而没有一个线程可以同时拿到所有资源正常执行。这种情况就是活锁。

扩展

通过jstack查看到死锁信息
1、使用jps找到执行代码的进程ID,启动类名为DeadLockTest(main函数所在类)的进程ID为11084 
jps
2、通过jstack命令找到java进程中死锁的线程锁信息,执行jstack -l 11084 
jstack -l 11084

最后输出:
===================================================
"thread2":
        at com.self.current.DeadLockTest$SynAddRunalbe.run(DeadLockTest.java:331)
        - waiting to lock <0x000000076b77e048> (a com.self.current.DeadLockTest$Obj1)
        - locked <0x000000076b780358> (a com.self.current.DeadLockTest$Obj2)
        at java.lang.Thread.run(Thread.java:748)
"thread1":
        at com.self.current.DeadLockTest$SynAddRunalbe.run(DeadLockTest.java:282)
        - waiting to lock <0x000000076b780358> (a com.self.current.DeadLockTest$Obj2)
        - locked <0x000000076b77e048> (a com.self.current.DeadLockTest$Obj1)
        at java.lang.Thread.run(Thread.java:748)

并发级别

由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别分为阻塞无饥饿无障碍无锁无等待5种。

阻塞——悲观锁

一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字或者重入锁时,我们得到的就是阻塞的线程。

synchronize关键字和重入锁都试图在执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。

例子:synchronize或ReentrantLock。

无饥饿(Starvation-Free)——公平与非公平锁

表示非公平锁与公平锁两种情况 。如果线程之间是有优先级的,那么线程调度的时候总是会倾向于先满足高优先级的线程。

对于非公平锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。但如果锁是公平的,按照先来后到的规则,那么饥饿就不会产生 。

例子:ReentrantLock 默认采用非公平锁,除非在构造方法中传入参数 true 。

//默认
public ReentrantLock() {
    sync = new NonfairSync();
}
//传入true or false
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

无障碍(Obstruction-Free)——乐观锁CAS

无障碍是一种最弱的非阻塞调度。两个线程如果无障碍地执行,那么不会因为临界区的问题导致一方被挂起。

对于无障碍的线程来说,一旦检测到这种情况,它就会立即对自己所做的修改进行回滚,确保数据安全。但如果没有数据竞争发生,那么线程就可以顺利完成自己的工作,走出临界区。

无障碍的多线程程序并不一定能顺畅运行。因为当临界区中存在严重的冲突时,所有的线程可能都会不断地回滚自己的操作,而没有一个线程可以走出临界区。这种情况会影响系统的正常执行。所以,一种可行的无障碍实现可以依赖一个"一致性标记"来实现。

数据库中乐观锁(通过版本号或者时间戳实现)。表中需要一个字段version(版本号),每次更新数据version+1,更新的时候将版本号作为条件进行更新,根据更新影响的行数判断更新是否成功,伪代码如下:

1.查询数据,此时版本号为w_v
2.打开事务
3.做一些业务操作
//此行会返回影响的行数c
4.update t set version = version+1 where id = 记录id and version = w_v;
5.if(c>0){        //提交事务    }else{        //回滚事务    }

多个线程更新同一条数据的时候,数据库会对当前数据加锁,同一时刻只有一个线程可以执行更新语句。

无锁(Lock-Free)

无锁的并发都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。 (注意有限步)

无锁的调用中,一个典型的特点是可能会包含一个无穷循环。在这个循环中,线程会不断尝试修改共享变量。如果没有冲突,修改成功,那么程序退出,否则继续尝试修改。但无论如何,无锁的并行总能保证有一个线程是可以胜出的,不至于全军覆没。至于临界区中竞争失败的线程,他们必须不断重试,直到自己获胜。如果运气很不好,总是尝试不成功,则会出现类似饥饿的现象,线程会停止。(并发量太大时会出现饥饿,这时候有必要改成阻塞锁)

下面就是一段无锁的示意代码,如果修改不成功,那么循环永远不会停止。

while(!atomicVar.compareAndSet(localVar, localVar+1)){        localVal = atomicVar.get();}

无等待——读写锁

无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步扩展。无等待要求所有线程都必须在有限步内完成,这样不会引起饥饿问题。如果限制这个步骤的上限,对循环次数的限制不同。分为为

  1. 有界无等待
  2. 线程数无关的无等待。

一种典型的无等待结果就是RCU(Read Copy Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,先获取原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。

并行的两个重要定律

为什么要使用并行程序 ?

第一,为了获得更好的性能;

第二,由于业务模型的需要,确实需要多个执行实体。

关于并行程序对性能的提高定律有二,Amdahl(阿姆达尔)定律Gustafson(古斯塔夫森 )定律

加速比定义:加速比 = 优化前系统耗时 / 优化后系统耗时

根据Amdahl定律,使用多核CPU对系统进行优化,优化的效果取决于CPU的数量,以及系统中串行化程序的比例。CPU数量越多,串行化比例越低,则优化效果越好。仅提高CPU数量而不降低程序的串行化比例,也无法提高系统的性能。

根据Gustafson定律,我们可以更容易地发现,如果串行化比例很小,并行化比例很大,那么加速比就是处理器的个数。只要不断地累加处理器,就能获得更快的速度。

总结

Gustafson定律和Amdahl定律的角度不同

Amdahl强调:当串行换比例一定时,加速比是有上限的,不管你堆叠多少个CPU参与计算,都不能突破这个上限。

Gustafson定律强调:如果可被并行化的代码所占比例足够大,那么加速比就能随着CPU的数量线性增长。

总的来说,提升性能的方法:想办法提升系统并行的比例(减少串行比例),同时增加CPU数量。

附图:

Amdahl公式的推倒过程

其中n表示处理器个数,T表示时间,T1表示优化前耗时(也就是只有1个处理器时的耗时),Tn表示使用n个处理器优化后的耗时。F是程序中只能串行执行的比例。

image.png

Gustafson公式的推倒过程

image.png

并发编程中JMM相关的一些概念

JMM(JAVA Memory Model:Java内存模型),由于并发程序要比串行程序复杂很多,其中一个重要原因是并发程序中数据访问一致性安全性将会受到严重挑战。如何保证一个线程可以看到正确的数据呢?

Q:如何保证一个线程可以看到正确的数据呢?

A:通过Java内存模型管理,JMM关键技术点都是围绕着多线程的原子性、可见性、有序性来建立的 。

原子性

原子性是指操作是不可分的,要么全部一起执行,要么不执行。java中实现原子操作的方法大致有2种:锁机制无锁CAS机制

可见性

可见性是指一个线程对共享变量的修改,对于另一个线程来说是否是可见的。

看一下java线程内存模型及规则:

image.png
  • 我们定义的所有变量都储存在 主内存
  • 每个线程都有自己 独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
  • 线程对共享变量所有的操作都必须在自己的工作内存中进行,不能直接从主内存中读写不能越级
  • 不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行。同级不能相互访问

例子:线程需要修改一个共享变量X,需要先把X从主内存复制一份到线程的工作内存,在自己的工作内存中修改完毕之后,再从工作内存中回写到主内存。如果线程对变量的操作没有刷写回主内存的话,仅仅改变了自己的工作内存的变量的副本,那么对于其他线程来说是不可见的。而如果另一个线程的变量没有读取主内存中的新的值,而是使用旧的值的话,同样的也可以列为不可见

共享变量可见性的实现原理:

线程A对共享变量的修改要被线程B及时看到的话,需要进过以下2个步骤:

1.线程A在自己的工作内存中修改变量之后,需要将变量的值刷新到主内存中 。

2.线程B要把主内存中变量的值更新到工作内存中。

关于线程可见性的控制,可以使用volatilesynchronized来实现。

有序性

有序性指的是程序按照代码的先后顺序执行。这是因为为了性能优化,编译器和处理器会进行指令重排序,有时候会改变程序语句的先后顺序。

例子:

在单例模式的实现上有一种双重检验锁定的方式,因为指令重排导致获取并发时获取到的单例可能是未正确初始化的单例。代码如下:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

我们先看 instance=newSingleton();

未被编译器优化的操作:

  1. 指令1:分配一款内存M
  2. 指令2:在内存M上初始化Singleton对象
  3. 指令3:将M的地址赋值给instance变量

编译器优化后的操作指令:

  1. 指令1:分配一块内存M
  2. 指令2:将M的地址赋值给instance变量
  3. 指令3:在内存M上初始化Singleton对象

现在有2个线程,刚好执行的代码被编译器优化过,过程如下:

image.png

最终线程B获取的instance是没有初始化的,此时去使用instance可能会产生一些意想不到的错误。

可以使用volatile修饰变量或者换成采用静态内部内的方式实现单例。

深入理解进程和线程

进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体

进程具有的特征:

  • 动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的
  • 并发性:任何进程都可以同其他进行一起并发执行
  • 独立性:进程是系统进行资源分配和调度的一个独立单位
  • 结构性:进程由程序,数据和进程控制块三部分组成

线程

线程是轻量级的进程,是程序执行的最小单元,使用多线程而不是多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程

我们用一张图来看一下线程的状态图:

image.png

Java中线程的状态分为6种 ,在java.lang.Thread中的State枚举中有定义,如:

public enum State {    NEW,    RUNNABLE,    BLOCKED,    WAITING,    TIMED_WAITING,    TERMINATED;}

Java线程的6种状态及切换

1. 初始(NEW):表示刚刚创建的线程,但还没有调用start()方法。
2. 运行(RUNNABLE):运行状态.Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
3. 阻塞(BLOCKED):阻塞状态,表示线程阻塞于锁。当线程在执行的过程中遇到了synchronized同步块,但这个同步块被其他线程已获取还未释放时,当前线程将进入阻塞状态,会暂停执行,直到获取到锁。当线程获取到锁之后,又会进入到运行状态(RUNNABLE)(维护在同步队列中)
4. 等待(WAITING):等待状态。进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。和TIMED_WAITING都表示等待状态,区别是WAITING会进入一个无时间限制的等,而TIMED_WAITING会进入一个有限的时间等待,那么等待的线程究竟在等什么呢?一般来说,WAITING的线程正式在等待一些特殊的事件,比如,通过wait()方法等待的线程在等待notify()方法,而通过join()方法等待的线程则会等待目标线程的终止。一旦等到期望的事件,线程就会再次进入RUNNABLE运行状态。(维护在等待队列中)
5. 超时等待(TIMED_WAITING):超时等待状态。该状态不同于WAITING,它可以在指定的时间后自行返回。
6. 终止(TERMINATED):结束状态,表示该线程已经执行完毕。
几个方法的比较
Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入TIMED_WAITING状态,但不释放对象锁,millis后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的CPU时间片,但不释放锁资源,由运行状态变为就绪状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。该方法与sleep()类似,只是不能由用户指定暂停多长时间。
thread.join()/thread.join(long millis),当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的)。
obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout) timeout时间到自动唤醒。
obj.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。
LockSupport.park()/LockSupport.parkNanos(long nanos),LockSupport.parkUntil(long deadlines), 当前线程进入WAITING/TIMED_WAITING状态。对比wait方法,不需要获得锁就可以让线程进入WAITING/TIMED_WAITING状态,需要通过LockSupport.unpark(Thread thread)唤醒。
线程的状态图
image.png image.png
进程与线程的一个简单解释

计算机的核心是CPU,整个操作系统就像一座工厂,时刻在运行 。进程就好比工厂的车间 ,它代表CPU所能处理的单个任务 。线程就好比车间里的工人。一个进程可以包括多个线程。 车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存。 每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。 一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。 还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。 这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突。

操作系统的设计,因此可以归结为三点:

(1)以多进程形式,允许多个任务同时运行;

(2)以多线程形式,允许单个任务分成不同的部分运行;

(3)提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源。

疑问:

Q:thread.join()/thread.join(long millis),当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIMED_WAITING状态,当前线程不会释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程一般情况下进入RUNNABLE状态,也有可能进入BLOCKED状态(因为join是基于wait实现的)。

调用其他线程的thread.join()方法,当前线程不会释放已经持有的对象锁,那如果进入了BLOCKED状态时会释放对象锁么?

相关文章

网友评论

      本文标题:Java高并发系列——检视阅读(一)

      本文链接:https://www.haomeiwen.com/subject/cfdbektx.html