Java并发编程的艺术(一)——Java并发的基础知识

上学期学习了计算机组成,跟着老师用C++模拟了一下CPU的流水线以及缓存之后发现正因为工程师对于性能与效率的极致追求才有了现在先进的各式计算机设备。最近看了java虚拟机的书后也发现了虚拟机优化这块用到了很多并行架构,比如G1,CMS垃圾回收器就是用到了多线程。在当今多核架构盛行的情况下,如何高效的利用多个CPU协同工作,成为提高程序运行速率的核心技术点之一。

多核心协同工作在编程界就是所谓的并发编程技术了。

关于并发编程的基础知识:

1. 上下文切换

任务从保存到再次加载的过程就是一次上下文切换

计算机的CPU其实并不是始终执行一个程序的,实际是通过给每个程序分配CPU时间片,当前程序的时间片一旦执行完,CPU会保存该程序的状态,转而去执行其他程序的时间片了,因为时间片比较短,一般只有几十毫秒,所以在人看来就像多个程序同时运行一样。具体的运转过程如下图:
在这里插入图片描述
CPU在不同的程序中进行切换是需要消耗资源的(耗电,耗存储空间之类的)那么如果想要提升性能,很容易想到的方法之一为减少上下文切换的次数从而减少资源开销。

如何减少上下文切换:
(1). 无锁并发编程,比如将数据ID按照Hash取模分段,不同线程处理不同段的数据
(2). CAS算法,compare and swap
(3). 使用最少线程,避免创建不需要的线程

2. 死锁

为了控制资源分配,人们发明了锁,就像卫生间门锁一样,当一个人在使用时必须把门锁起来,这样后面来的人需要等待,而不是直接闯入进去。
但是一旦出现两个已经上锁了的卫生间A,B。A卫生间里的人拿着B卫生间的门锁钥匙,而B卫生间里的人手上拿着A的门钥匙,如果是这种情况,A,B将永远打不开,外面排队的人只能无奈的等着,这就是所谓的死锁。

下面写一个简易的死锁:

public class DeadLock{

    private final String A = "A";
    private final String B = "B";

    public void deadLock(){
    
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
            	//线程1中获取A的资源后再尝试获取B的资源
                synchronized (A){
                    try{
                        Thread.sleep(1000);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                    synchronized (B){
                        System.out.println("thread A");
                    }
                }
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
            	//线程2中先获取B资源后再尝试获取A资源
                synchronized (B){
                    try{
                        Thread.sleep(1000);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                    synchronized (A){
                        System.out.println("Thread B");
                    }
                }
            }
        });
        
        t1.start();
        t2.start();
    }

    public static void main(String[] args) {
        DeadLock dl = new DeadLock();
        dl.deadLock();
    }
}

运行程序会发现程序不中断但不输出任何结果。
通过jps以及jstack命令可以发现该程序出现了一个死锁:
在这里插入图片描述在这里插入图片描述
如何避免死锁:
(1).避免一个线程同时获取多个锁
(2).避免一个线程在锁内同时占用多个资源,尽量保证一个锁占用一个资源
(3).尝试使用定时锁
(4).对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会解锁失败

CAS: compare and swap
CAS算法是一个能保证共享变量原子操作而不需要加锁的算法
具体实现逻辑:在操作期间先比较共享变量的旧值有没有变化,如果没有变化则替换成新值,若变化了则不进行交换。

CAS的问题
(1). ABA问题 A变化为B之后再变成A 对于CAS来说值并没有变化,但实际上发生过变化,CAS却检测不到
解决方案:增加版本号
(2).循环时间长开销大,如果自旋CAS长时间不成功,会给CPU带来非常大的执行开销
(3).只能保证一个共享变量的原子操作
若想对多个共享变量进行操作则需要使用锁

3. volatile

volatile: 一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

volatile修饰的共享变量执行写操作时汇编语言中会出现Lock指令,Lock指令有以下的作用:

  1. lock前缀指令会引起处理器缓存回写到内存
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

4. synchronized

JVM基于进入和退出Monitor对象来实现方法的同步和代码块的同步,这就是synchronized关键字的实现方式。锁的信息是存放在对象头内的。

锁的四种状态:
无锁状态,偏向锁,轻量级锁,重量级锁
TIPS: 锁只能升级不能降级,目的是为了提高获得锁和释放锁的效率

偏向锁:
大多数情况下,锁不存在多线程竞争,而且总是有同一个线程多次获得,为了让线程获得锁的代价更低引入偏向锁

自旋锁:指当一个线程获取锁的时候,如果锁已经被其他线程获取,那该线程将循环等待,然后不断判断锁是否能被成功获取,直到获取到锁才会退出循环

5. 原子操作的实现

  1. 总线锁:Lock信号,当出现该信号,其他处理器的请求会被阻塞住,那么该处理器可以独占共享内存
    缺点:总线锁会把CPU与内存之间的通信锁住,这使得锁定期间,其他处理器无法操作其他内存地址数据,会影响性能
  2. 缓存锁:频繁使用的内存会存在处理器的L1,L2,L3高速缓存里,所以原子操作可以直接在处理器内部缓存内进行,不需要声明总线锁
    处理器不使用缓存锁的情况:
    (1)操作的数据不能被缓存在处理器内部,或者操作的数据跨了多个缓存行,处理器会调用总线锁定
    (2)处理器不支持缓存锁定

在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章