Java并发编程(2)-并发基础(二)

续Java并发编程(1)-并发基础(一)

一、从JVM内存模型来看线程安全问题

JVM运行时数据区:

数据.png

堆,栈是JVM运行时主要的数据区.另外还有程序计数器(PC寄存器)、方法区、运行时常量池、本地方法栈等等。
栈中存放当前方法中使用到的变量指针,属于线程私有数据区
堆中存放new 出来的对象等.属于共享区域

Java内存模型JMM:

内存模型.png

java内存模型规定了如果使用共享变量,必须将变量复制到本地内存中,生成一个变量副本.变量处理结束后再刷新到主缓存中

其中主缓存相当于运行时数据区中堆的内容, 本地内存相当于栈中的内容. 他们含义不同并不等价,这里只是大概类比下他们所处位置方便理解.

public class TestThread {
    static class Add {
        public  int i=0;
        public  void add() {
            i++;
        }
    }
Add add = new Add();
Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 100000000; i++) {
            add.add();
        }
    }
});
Thread thread1 = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("thread1:"+add.i);
        for (int i = 0; i < 100000000; i++) {
            add.add();
        }
    }
});
thread.start();thread1.start();thread.join();thread1.join();

System.out.println(add.i);

控制台: 111245542

上面的代码运行结果显示并不等于200000000, 原因就是add对象中i的值修改后并不能及时刷新到主内存中,当获取i的值时也可能或者的是当前线程本地内存中的值.

二、synchronized、volatile

synchronized

public class TestThread {
    static class Add {
        public  int i=0;
        public synchronized void add() {
            i++;
        }
    }

通过synchronized修饰的add方法就可以解决上边的问题

  • synchronized关键字:

synchronized是java提供的原子性的内置锁.线程执行代码进入到synchronized代码块时会尝试获取内部锁,或者成功其他线程再访问就会被阻塞挂起. 运行结束或者wait等待的时候会释放锁.

  • synchronized的内存语意:内存可见行

在synchronized代码块中使用的变量不会再从本地内存获取,而是重新从主内存获取.方法执行介绍也会刷新到主内存.然后再释放锁.

static boolean flag = true;
public static void main(String[] args) throws NoSuchFieldException, InterruptedException {

    Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        while (true) {
                if (!flag) {
                    break;
            }
        }
    }
});
    thread.start();
    Thread.sleep(1000);
    flag = false;
    thread.join();
    System.out.println("end");

处于thread线程的本地内存中flag=true. 后续主线程将主内存中的flag修改成了false.thread线程的本地内存对主内存并不可见.所以thread一直处于while (true)
的死循环中.

继续用synchronized解决下:

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        while (true) {
            synchronized (flag) {
                if (!flag) {
                    break;
                }
            }
        }
    }
});

可以看到我们对flag加了把锁,每次走到这个代码块都会竞争锁.并且从主内存中重新读取flag的值. 如果是多个线程就会发生竞争关系,有些线程发生阻塞从用户态切到了内核态.上下文会频繁的切换

volatile关键字:
对于内存可见性,使用synchronized开销太大了,java提供了volatile关键字.确保一个线程修改了共享变量,另一个线程能立即可见. 线程再写入变量会直接刷新到主内存,使用到改变量大线程也会重新从主内存中读取.

但是第一个例子使用volatile关键字就不行了,因为volatile只能保证内存可见,不能保证原子性, i++;是一个复合操作. i即使读到了最新的值, 读值与i+1之间其他线程仍有可能将i的值修改.

public int i=0;
public synchronized void add() {
i++;
}

二、CAS、ABA

我们知道了volatile只能保证内存可见性,而保证原子性的synchronized会有锁开销的代价.Java提供了一种非阻塞的原子性操作CAS即compare and swap .其通过硬件保证了获取+比较—+更新三个操作的原子性.

  • CAS:
    CAS的设计的思想是先取到当前变量的值,然后比较地址的数据与预期的当前值是否一致,如果一致就修改.不一致就重新取到当前变量的值,然后比较地址的数据与预期的当前值是否一致.

  • ABA:
    上述设计存在一个问题就是如果线程A取到了变量X=A的值,然后在比较更新前 线程B修改了变量X的值x=B; 线程B修改了变量C的值x=A; 这是比较发现值是相等的做了更新.那么这种情况就违背了原子性了.在修改变量前,其他两个线程都对变量值做了修改. 这个就叫ABA问题,一般解决办法就是在值的基础上增加个版本号或者增加个时间戳作为修改记录.

  • unsafe:这里提下unsafe方便理解下面的代码.Unsafe是JDK rt.jar包中的类,类中的方法都是native方法,里面实现了一硬件级别的原子操作. 通过下面代码继续了解cas与unsafe

java中AtomicInteger等就是用CAS+unsafe设计实现的,AtomicInteger是对int类型的数据封装,提供的原子性操作的类:

static class Add {
    AtomicInteger ai = new AtomicInteger(0);
    public  void addAtomicInteger() {
       ai.getAndIncrement();
    }
    
    public  int i=0;
    public  synchronized void add() {
        i++;
    }
}
Thread thread0 = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++){
            add.add();
            add.addAtomicInteger();
        }
    }
});
Thread thread1 = new Thread(new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++){
            add.add();
            add.addAtomicInteger();
        }
    }
});

上面代码中实现了线程安全的int类型的++操作,i变量由synchronized加锁实现,ai变量由AtomicInteger实现.其中addAtomicInteger就是AtomicInteger类提供的int类型的原子性++方法.

一起看下AtomicInteger类中的主要代码

public class AtomicInteger extends Number implements java.io.Serializable {

     //JDK提供的底层cas底层实现类 单例的 饿汉模式
     private static final Unsafe unsafe = Unsafe.getUnsafe();
     
     //私有的内存可见int变量
     private volatile int value;
     
     //数据value在这个类内存空间相对偏移量
     private static final long valueOffset;
     //
     static {
      try {
             valueOffset = unsafe.objectFieldOffset
             (AtomicInteger.class.getDeclaredField("value"));
          } catch (Exception ex) { throw new Error(ex); }
     }

     //构造函数
     public AtomicInteger(int initialValue) {
            value = initialValue;
     }
     
     //原子性+1方法
     public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
     }
}

objectFieldOffset是unsafe提供的一个返回指定变量在所属类中的内存偏移地址方法.

valueOffset的初始化为什么是静态的代码块?, 静态代码块加载类时只执行一次,第二次new AtomicInteger()不会再执行了. 也就是说valueOffset的值是在当前操作系统下值是固定的

valueOffset.png

valueOffset就是图中区间标识的偏移量.

// 通过反射得到theUnsafe对应的Field对象
Field field = Unsafe.class.getDeclaredField("theUnsafe");
// 设置该Field为可访问
field.setAccessible(true);
// 通过Field得到该Field对应的具体对象,传入null是因为该Field为static的
Unsafe unsafe = (Unsafe) field.get(null);
try {
    long value = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    System.out.println(value);
} catch (Exception ex) { throw new Error(ex); }

从代码输出来看value的值每次都是12.

Unsafe实例获取为什么是反射没有用源代码中的

 private static final Unsafe unsafe = Unsafe.getUnsafe();

因为类的构造函数是私有的并且提供的获取实例的方法getUnsafe中只允许从引导类加载器(Bootstrap)加载(main方法是AppClassLoader加载)!VM.isSystemDomainLoader(var0.getClassLoader()) 也就是系统自己才能使用. 这也表明了jdk并不希望我们直接使用这个类,从命名也能看出直接名为不安全的类.因为底层有许多危险的直接操作内存不受jvm管控的方法.

@CallerSensitive
public static Unsafe getUnsafe() {
    Class var0 = Reflection.getCallerClass();
    if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
        throw new SecurityException("Unsafe");
    } else {
        return theUnsafe;
    }
}

继续来看原子性加方法,入参是当前对象,int数据相对当前对象地址的偏移量, 加法的加数

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
  • this.getIntVolatile(var1, var2):通过内存地址或者int数据的值,并且是内存可见的形式.
  • compareAndSwapInt(var1, var2, var5, var5 + var4):入参分别是当前对象,数据相对地址偏移量,通过内存地址取到数据的值,将要修改后的值. 里边是调用的native方法,通过c++等底层保证了(var5与地址值比较,相当情况下修改变量的值 然后返回true)操作的原子性. 如果修改失败就返回fasle. 上层while循环去调用compareAndSwapInt直到修改成功为止. 期间没有阻塞
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

三、指令重排序

int a = 0;
int b = 1;
int c = a+b;

java内存模型允许编译器和处理器对指令重新排序用以提高程序运行效率, 排序会保证单线程下的依赖顺序,运行结果不会异于单线程下原顺序的执行结果. 如上面代码c=a+b 重排序后不会在a b初始化之前执行,但是a与b的初始化顺序就不一定了.

所有在多线程编程下需要考虑到指令重排序带来的影响

//需要处理的标志位
public static boolean dealFlag = false;
//需要处理的id集合
public static List<String> idList;
//处理数据
public static void deal(){
      while(dealFlag){
          for(String id : idList){
              System.out.println(id);
          }
          dealFlag = false;
      }
}
//查询数据
public static void selectIdList(){
    idList = new ArrayList<String>(); idList.add("1"); idList.add("2"); idList.add("3");
    dealFlag = true;
}
    new Thread(new Runnable() {
        @Override
        public void run() {
            deal();
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            selectIdList();
        }
    }).start();
}

如果 dealFlag = true;指令重排序到了idList之前. 那么处理数据的线程就有可能处理不到数据,因为数据那边还没产出.

使用volatile关键字除了内存可见还有禁止重排序的作用, volatile修饰dealFlag可以避免发生上述问题.

禁止重排序规则:

  • volatile dealFlag = true;这是一个写操作,volatile修饰的写操作之前的代码不会被排序到写之后执行
  • while(dealFlag)是读操作,volatile修饰的读操作之后的代码不会排序到读之前执行.
  • 所以不是遇到volatile了就所有代码都不会进行重排序了. volatile dealFlag = true之后的代码可以排序到之前, while(dealFlag)之前的排序可能会排序到之后执行

排序指的是编译后的计算机指令,而不是某一行代码.我们来写一个double check的懒汉单例模式

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

第一个if (lock == null)是懒汉模式的思想, 只有需要用到实例了才会new个对象.synchronized是多线程情况下多个线程同时走完了if (lock == null) 并且都是true,直接new会出来多个对象.所以下边的实例创建加了个锁. 第二个 if (lock == null) 是防止当前线程准备创建对象阻塞在获取锁上,其他线程刚好创建完对象释放了锁. 从计算机指令的重排序来看下上边有可能出现的问题
lock = new Lock();在编译成汇编语言后是多条指令大概是

  1. 给对象分配内存空间
  2. 初始化构造器
  3. lock引用地址指向分配的内存空间

lock引用地址指向分配的内存空间执行后if (lock == null)就是false了, 如果指令2排序到了指令3的后边 ,if (lock == null)判断为否直接return了个尚未初始化的lock对象;完整的线程安全的懒汉单例模式应该是这样

public static volatile Lock lock = null;

四、伪共享

cpu的缓存最小单位是行,即将使用的数据会读到缓存行,如果缓存数据大小不够一行,那么会把相邻的数据也读到缓存行中.比如long a, long b但线程下频繁使用a,b变量是有好处的全部从缓存读取. 多线程情况下修改a,b会竞争同一缓存行影响效率,

java8之前一般使用无效数据来填充真实数据,long a, long e, long c, long d比如额外定义c,d,e来补充a缓存行后边的空余位置

java8提供了@sun.misc.Contended注解,可以修饰变量和类上. 这个注解过多使用会浪费内存,jdk限制了只能在核心包中使用,其他路径需要修改jvm参数来打开限制.

五、锁的类分类

乐观锁悲观锁源于数据库中的名词, java并发包也有类型的设计

  • 乐观锁 操作数据前认为相关数据很小概率涉及多线程竞争. 修改完数据提交的时候确定是否可以提交(一般使用CAS实现)
  • 悲观锁 操作数据前认为相关数据很大概率会涉及多线程竞争.修改数据前就将数据加锁,修改后释放锁

公平锁、非公平锁

  • 公平锁,多个线程竞争共享资源,获取到锁的顺序与申请锁的顺序是一致的.
  • 非公平锁,多个线程竞争共享资源,获取到锁的顺序是随机的无序的.
//默认非公平锁
ReentrantLock fairLock = new ReentrantLock();
//非公平锁
ReentrantLock fairLock1 = new ReentrantLock(false);
//公平锁
ReentrantLock noFairLock = new ReentrantLock(true);

独占锁、共享锁

  • 独占锁是悲观锁.每次访问数据都加上锁,写操作相关锁一般都是独占锁.
  • 为了提高并发和执行效率,某些场景可以把读设置为共享锁. 多个线程可以不阻塞的读取共享变量.

可重入锁,自旋锁

  • 可重入锁当前线程已经获取到锁了,可以再次获取本对象的锁
  • 线程获取锁失败会被挂起,从内核状态切换到用户状态.获取到锁又需要从用户状态切换到内核状态.频繁的切换又很耗资源,而共享资源通常很快很被释放. 在获取锁失败后挂起前,自旋锁会循环去尝试获取锁,自旋一定次数仍然获取不到再挂起切换线程状态
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值