距离上次更新已经过了很久了,最近一直在弄公司的三个新产品,目前也终于告一段落了。
目前的生产环境系统,CPU性能基本都是过剩的,如何提升系统的性能与使用率呢? 压榨CPU的性能就很有必要了,这里我们先一步步的来看看如何提升CPU使用率。
在说线程之前,我们先来说说进程,学过操作系统的小伙伴的都知道进程是OS分配资源的最小单元,一般由程序,数据集合和进程控制块三部分组成。
这里讲起来还是比较抽象,举两个例子:
1. 电脑打开一个应用程序,会启动一个进程,再打开一个应用程序,会再启动一个进程;
2. 微服务与单体服务不同模块的根本区别是什么?一个微服务是一个进程,单体服务的不同模块是同一个进程,微服务之间的交互是跨进程交互。
线程是程序执行的最小单位,进程是线程的容器,不同的线程公用进程的资源,那么线程又如何使用CPU资源呢? 有哪些状态呢?
1. 线程创建后,会进入就绪状态;
2. 就绪状态线程在获取到CPU资源时会变更为运行状态;
3. suspend(),sleep()方法被调用,使用wait()来等待条件变量,线程处于I/O请求的等待时,线程会从运行进入阻塞状态;
4. 线程收到stop()指令时,会进入死亡状态,而运行时线程还可通过destroy()指令进入死亡状态;
5. 阻塞状态线程会在收到notify(), notifyAll(), resume(), I/O执行完成后,恢复就绪状态;
6. 运行状态线程会在时间片运行完成后,变为就绪状态,这里关于时间片有兴趣了解的可以看看OS相关资料。
那么我们在这里留两个小问题:
1. 线程与CPU核数有什么关系?这会涉及到多线程场景下一些问题;
2. 一个系统用多少线程合适?
线程是OS级的,纤程是线程中的线程,切换和调度不需要经过OS(操作系统)。其资源占用更少(一个线程大约为1M,一个纤程大约为4K),切换更轻量更快(不需要OS调度)。
天然支持纤程的语言有Go、Scala、Kotlin,Java官方未支持,但是可以通过集成Quasar库支持纤程,当我们需要进行大量快速计算时,使用纤程会在性能上领先线程。
这里我们还要再补充一些基础知识,这些知识会包括一些计算机组成原理的内容。如果对这块比较了解或没兴趣的都可以跳过。
我们在这里以一个游戏举例,我们回忆一下这样的场景(以阿婆主中学时的例子举例吧):
1. 阿婆主在电脑里安装了暗黑破坏神2,占用了硬盘空间2G,计算机内存64M,CPU缓存64K(似乎那时还没有多级缓存?);
2. 安装好后迫不及待点击运行.exe,于是系统进入loading,这时计算机会从硬盘把部分内容加载进内存,硬盘咔咔地响;
3. 进入界面后做一些点击,发现硬盘灯没有闪闪闪,整个操作也响应很快,说明没有发生与硬盘的数据交换,数据在内存与寄存器中,这里的速度会比硬盘快;
4. 跑到野外,来了一片小怪,放了一个技能,原本流畅的运行开始卡了,但是硬盘依然没有咔咔响,这时候可能是什么呢?大概率就是内存与寄存器及CPU缓存在做数据交换,因为缓存速度又是内存的100倍。我放了一个技能集中一片小怪,CPU要做大量运算,这些运算需要不同数据,可能是多线程在运行,因此会发生大量的线程切换以及缓存内存数据交换(缓存无法命中数据时,会去内存获取)。
以上这里涉及到了磁盘、内存、CPU(缓存、寄存器组、计算单元),CPU的每个核都包含自己的缓存、寄存器组、计算单元,而要提升系统的性能,我们就要去思考如何更好利用这些组件,这里我们大概看下来是不是应该让数据尽量多的从缓存获取(寄存器是存放计算的数据,缓存存放挂起的线程的中间数据,缓存不足时较早的数据会被挤出只得再从内存获取)。
那么这里又引出了另一个问题,大家思考一下,一个CPU核会有自己的计算单元与缓存,那么如果一组数据存在于一个64bit(缓存长度),不同核心分别进行了该数组的值修改,会发生什么情况?对数值与性能有什么影响?超线程如何提升CPU性能?
我们看到CPU在等待IO时,也会阻塞,那么这时候我们能否把等待时间使用起来?那就再来一个线程嘛,在上一个线程等待IO时,我来另一个线程把运算资源使用起来,这样是不是就把CPU充分利用起来了。
所以什么是多线程呢?就是在外部看来原本排队执行的工作,现在一起在做了(虽然在只有一个计算单元时,其实内部也是交替在进行),这样等待时间也被利用起来了。
既然多线程这么好,我们直接多线程不就好了,为什么还要单线程呢?这里面有哪些问题可能存在呢?我们要如何规避呢?
下面我们来看看多线程主要会存在的一些问题,如果不了解这些问题,那么多线程可能就是一个噩梦,会让我们的系统发生完全不可预知的运行结果。
要理解可见性,我们就要回到前面的基础知识,要去理解内存与CPU的缓存(多级缓存):
1. 当线程将数据读取进缓存并更改,该数据不会更新至其他缓存;
2. 另一个线程使用该数据时,并不知道另一个线程已更改该数据,获取到的依然是原值;
3. 最终在多线程情况下,该数值在每个线程都不一样了。
那要怎么办呢?这个在CPU的底层都已经做了协议,叫缓存一致性协议,这是个硬件级的协议,每个缓存会被标记为modified、shared、exclusive、invalid四个状态,基于该协议系统会在多级缓存之间做数据的同步。
JAVA中的实现:
一个变量默认没有做到可见性,我们可以通过volatile或synchronized关键字实现变量的可见性,那么区别又在哪里呢?
很多小伙伴知道synchronized来加锁,它也可以用来解决可见性问题,但是它比volatile重一些,而volatile解决可见性也是因其底层的Lock指令实现。基于这个差异,synchronized不仅可以解决可见性,也可以解决原子性问题,很多小伙伴用synchronized可能主要是解决原子性问题,但是这顺带解决了可见性问题。
问题3:这里我们对前面的一个问题进行扩展,为了在缓存间做数据同步,势必会有额外性能消耗,如何减少一组运算数据在缓存间同步的性能消耗?(答案见末尾)
我们先来看看原子性的描述:原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。及时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
那么怎么保证原子性呢?这里常常被提及的就是CAS与ABA。
java内存模型中定义了8中操作都是原子的,不可再分的。
lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
上面的这些指令操作是相当底层的,可以作为扩展知识面掌握下。那么如何理解这些指令了?比如,把一个变量从主内存中复制到工作内存中就需要执行read,load操作,将工作内存同步到主内存中就需要执行store,write操作。注意的是:java内存模型只是要求上述两个操作是顺序执行的并不是连续执行的。也就是说read和load之间可以插入其他指令,store和writer可以插入其他指令。比如对主内存中的a,b进行访问就可以出现这样的操作顺序:read a,read b, load b,load a。
由原子性变量操作read,load,use,assign,store,write,可以大致认为基本数据类型的访问读写具备原子性(例外就是long和double的非原子性协定)
synchronized
上面一共有八条原子操作,其中六条可以满足基本数据类型的访问读写具备原子性,还剩下lock和unlock两条原子操作。如果我们需要更大范围的原子性操作就可以使用lock和unlock原子操作。尽管jvm没有把lock和unlock开放给我们使用,但jvm以更高层次的指令monitorenter和monitorexit指令开放给我们使用,反应到java代码中就是synchronized关键字,也就是说synchronized满足原子性。
那么在这里我们是否可以理解为了保证一个数据的原子性,为什么可以使用synchronized了,因为它触发了lock和unlock,而根据happens-before原则,下一次lock发生在unlock之后,当然这里要注意死锁的发生。
那么除了synchronized还有别的锁方式吗?当然是有的,synchronized是重量级锁,它的调度会由OS完成,而我们还有轻量级锁,如自旋锁。
轻量级锁因为是由程序实现的,因此不需要进入OS的等待队列,自己进入一个循环判断资源锁是否释放(所以叫自旋锁,自己在这里转圈等待),而OS的等待队列只有一个,轻量级锁的等待队列可以有多个,因此可以根据情况做精准唤醒。
那么有哪些轻量级锁的实现呢?
比如AtomicInteger就是一个轻量级锁的方案,它在汇编层调用了compare and exchange,该指令在多核情况下是可以被其它核的指令打断的,那么引入一个问题:
问题4:这种情况下是否可保证数据原子性?
问题5:还有哪些锁实现?是否轻量级锁就优于重量级锁?
有序性的问题简单说起来便是,系统的执行顺序不一定与我们代码的编写顺序一致,那么这是否表示我们的系统都不可靠了?有小伙伴说,我线性执行的代码没有这样的问题啊,这个问题是不是忽悠?
那么我们来说说什么情况下不会存在有序性问题:
1. 前后有依赖关系的逻辑是不会乱序的;
2. 单线程的执行结果不会被影响。
大家注意情况2,单线程的执行结果不会被影响,为什么强调了单线程?如果是多线程呢?一个变量如果多个线程都要用到,我们试想原本我们希望在线程1初始化,线程2使用,可是万一线程2先执行了会怎么样?
我们再来看一个情况,当新建一个对象时,程序会先初始化一个变量(JAVA会赋予默认值,而C会获取原内存地址的值),这时另一个线程打断了该对象的创建并来获取这个变量的值会发生什么?这个值是错误的,而C在这时候会拿到一个不可预知的值。
那么如何解决有序性问题呢?
volatile的底层是使用内存屏障来保证有序性的(让一个Cpu缓存中的状态(变量)对其他Cpu缓存可见的一种技术)。
volatile变量有条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。并且这个规则具有传递性,也就是说:使用volatile修饰就可以避免重排序和内存可见性问题。写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。读volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
这里的内存屏障是基于JVM规范的JSR内存屏障,基于store与load的排列组合。
问题6:Happens-Before原则
有小伙伴会想,有序性是由线程切换引起的,那么我们加锁是否也可以解决这个问题呢?是可以的,但正如前面所说,锁会比volatile重一些。
问题3:参考disruptor,对一个内存行做前后填充,使不同运算数据始终在不同缓存行,减少缓存同步带来的性能消耗;
问题4:数据被锁了,即使中间被打断,锁还是该线程持有,依然可以保证其原子性,但是Atomic变量无法保证有序性;
问题5:还可以用ReentrantLock,它是CAS+AQS,实现了公平锁与非公平锁,而AQS是一个用于构建锁和同步容器的框架。concurrent包内许多类都是基于AQS构建,例如ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,FutureTask等。AQS解决了在实现同步容器时设计的大量细节问题。
轻量锁与重量锁谁性能更好,视使用场景而定,轻量锁不适合计算时间长等待时间久的场景,因为轻量锁的所有等待线程都会消耗CPU资源,而重量锁的唤醒资源消耗虽然大一些,但是其等待线程并不消耗CPU资源;
问题6:happens-before原则(先行发生原则)
特性 | Atomic变量 | volatile关键字 | Lock接口 | synchronized关键字 |
---|---|---|---|---|
原子性 | 可以保障 | 无法保障 | 可以保障 | 可以保障 |
可见性 | 可以保障 | 可以保障 | 可以保障 | 可以保障 |
有序性 | 无法保障 | 一定程度保障 | 可以保障 | 可以保障 |
多线程实用与面试