首页 > 编程知识 正文

java类的关键字是什么,多线程共享变量 voliate

时间:2023-05-04 19:22:01 阅读:15270 作者:247

1、确保内存可视性

内存可见性,即线程a对volatile变量的更改,其他线程获取的volatile变量是最新的。

内存可见性需要提到Java的内存模型,如下图所示。

如上图所示,所有线程的共享变量都存储在主内存中,每个线程都有自己的工作内存。 每个线程不直接操作主内存中的变量,而是将主内存中的变量副本放入自己的工作内存中,只处理工作内存中的数据。 修改完成后,将修改后的结果返回主存储器。 每个线程只能操作自己工作存储器中的变量,不能直接访问对方工作存储器中的变量。 线程间变量值的传递必须在主内存中进行。

上述Java内存模型在单线程环境中不会出现问题,但在多线程环境中可能会出现脏数据。 例如,假设AB的两个线程同时获得变量I并执行增量操作。 a线程将变量I放入自己的工作存储器中进行1次操作,此时线程a还没有将修正后的值刷新回主存储器中,但此时线程b也从主存储器中接收了修正前的变量I,再次进行了1次操作。 最后,a和b线程将各自的结果刷新回主内存,变量I只进行了一次操作,实际上a和b累计进行了两次操作,结果发生了错误。 这是因为线程b读取了变量I的脏数据。

此时,如果对变量I添加volatile关键字修饰,则a线程在变量I的值变更后立即刷新到主存储器,即使其他线程读取变量值也无效,可以保证强制从主存储器读取变量值,从而

1.1 MESI缓存完整性协议

volatile的可见性是通过将程序集与Lock前缀指令结合以触发底层MESI缓存完整性协议来实现的。 当然这个协议有很多,但最常用的是MESI。 MESI表示4种状态,如下所示。 状态描述修改(Modified )此时高速缓存线的数据与主存储器中的数据不匹配,数据只存在于该工作存储器中。 其他线程从主内存读取共享变量值的操作将延迟,直到将高速缓存行中的数据写入主内存

e(exclusive )此时,高速缓存线的数据与主存储器的数据一致,数据只存在于本工作存储器中。 在这种情况下,其他线程必须监听读取主内存中的共享变量的操作,如果发生,则高速缓存行必须处于共享状态

s共享(Shared )时,高速缓存线的数据与主存储器的数据一致,数据存在于多个工作存储器中。 此时,其他线程接收到禁用缓存行的请求,如果发生该请求,则必须禁用缓存行

I无效(Invalid )缓存线此时无效

现在,如果某个cpu在主存储器中得到变量x的值,最初是1,则将其放入自己的工作存储器中。 此时的状态独占状态e,此时其他的cpu也得到该x的值并将其存储在自己的工作存储器中。 此时,该cpu一个接一个地监听存储器总线,并注意到多个cpu取得了这个x。 此时,这两个cpu取得的x的值的状态都是共享状态s。 第一个cpu将自己工作存储器中的x的值带到自己的ALU计算单元进行计算,返回的x的值变为2,然后传递到存储器总线,将此时的x的状态设为修正状态m。 同时,另一个cpu此时也相继去监听存储器总线,发现另一个cpu已经在这个x上处于修改状态,于是它内部的x状态被设为无效状态I,第一个cpu将修改后的值刷新回主存储器,然后再刷新新的值这个谁先改变了x的值有可能在同一时间被修正。 在这种情况下,cpu通过底层硬件在同一指令周期内进行裁决,裁决是谁修改的处于修正状态,另一个处于无效状态,被废弃或复盖(存在争议)。

当然,MESI也可能失效。 高速缓存的最小单位是高速缓存线,如果当前共享数据的长度超过高速缓存线的长度,MESI协议将失败,在这种情况下总线锁定机制将启动。 当第一个线程的cpu得到这个x时,不允许其他线程获取这个x的值。

2、禁止命令重排

指令的执行顺序不一定按我们创建的顺序执行,为了确保执行效率,JVM (包括CPU )可以对指令进行重新排序。 例如,int i=1;

int j=2;

上述两个赋值语句位于同一线程中,根据程序上的顺序,“int i=1;” 的操作为“int j=2; 中必须先行,“int j=2; ”的代码可能完全由处理器先执行。 对于单线程,JVM确保排序后的执行结果与排序前的结果相匹配。 但在多线程场景中,情况并不总是如此。 最典型的例子是双重检查封锁版的单实例实现。 代码如下。 公共类单一{

私密性评估服务;

私有singleton () }

publicstaticsingletongetinstance (

if(instance==null ) {

同步(singleton.class ) {

if(instance==null ) {

instance=new Singleton (;

}

p>

}

}

return instance;

}

}

由上可以看到,instance变量被volatile关键字所修饰,但是如果去掉该关键字,就不能保证该代码执行的正确性。这是因为“instance = new Singleton();”这行代码并不是原子操作,其在JVM中被分为如下三个阶段执行:为instance分配内存

初始化instance

将instance变量指向分配的内存空间

由于JVM可能存在重排序,上述的二三步骤没有依赖的关系,可能会出现先执行第三步,后执行第二步的情况。也就是说可能会出现instance变量还没初始化完成,其他线程就已经判断了该变量值不为null,结果返回了一个没有初始化完成的半成品的情况。而加上volatile关键字修饰后,可以保证instance变量的操作不会被JVM所重排序,每个线程都是按照上述一二三的步骤顺序的执行,这样就不会出现问题。

2.1 内存屏障

volatile有序性是通过内存屏障实现的。JVM和CPU都会对指令做重排优化,所以在指令间插入一个屏障点,就告诉JVM和CPU,不能进行重排优化。具体的会分为读读、读写、写读、写写屏障这四种,同时它也会有一些插入屏障点的策略,下面是JMM基于保守策略的内存屏障点插入策略:屏障点描述每个volatile写的前面插入一个store-store屏障禁止上面的普通写和下面的volatile写重排序

每个volatile写的后面插入一个store-load屏障禁止上面的volatile写与下面的volatile读/写重排序

每个volatile读的后面插入一个load-load屏障禁止下面的普通读和上面的volatile读重排序

每个volatile读的后面插入一个load-store屏障禁止下面的普通写和上面的volatile读重排序

上面的插入策略非常保守,但是它可以保证在任意处理器平台上的正确性。在实际执行时,编译器可以省略没必要的屏障点,同时在某些处理器上会做进一步的优化。

3、不保证原子性

需要重点说明的一点是,尽管volatile关键字可以保证内存可见性和有序性,但不能保证原子性。也就是说,对volatile修饰的变量进行的操作,不保证多线程安全。请看以下的例子:public class Test {

private static CountDownLatch countDownLatch = new CountDownLatch(1000);

private volatile static int num = 0;

public static void main(String[] args) {

ExecutorService executor = Executors.newCachedThreadPool();

for (int i = 0; i < 1000; i++) {

executor.execute(() -> {

try {

num++;

} catch (Exception e) {

e.printStackTrace();

} finally {

countDownLatch.countDown();

}

});

}

try {

countDownLatch.await();

} catch (InterruptedException e) {

e.printStackTrace();

}

executor.shutdown();

System.out.println(num);

}

}

静态变量num被volatile所修饰,并且同时开启1000个线程对其进行累加的操作,按道理来说,其结果应该为1000,但实际的情况是,每次运行结果可能都是一个小于1000的数字(也有结果为1000的时候,但出现几率很小),并且不固定。那么这是为什么呢?原因是因为“num++;”这行代码并不是原子操作,尽管它被volatile所修饰了也依然如此。++操作的执行过程如下面所示:首先获取变量i的值

将该变量的值+1

将该变量的值写回到对应的主内存中

虽然每次获取num值的时候,也就是执行上述第一步的时候,都拿到的是主内存的最新变量值,但是在进行第二步num+1的时候,可能其他线程在此期间已经对num做了修改,这时候就会触发MESI协议的失效动作,将该线程内部的值作废。那么该次+1的动作就会失效了,也就是少加了一次1。比如说:线程A在执行第一步的时候读取到此时num的值为3,然后在执行第二步之前,其他多个线程已经对该值进行了修改,使得num值变为了4。而线程A此时的num值就会失效,重新从主内存中读取最新值。也就是两个线程做了两次+1的动作,但实际的结果最后只加了一次1。所以这也就是最后的执行结果为什么大概率会是一个小于1000的值的原因。

所以如果要解决上面代码的多线程安全问题,可以采取加锁synchronized的方式,也可以使用JUC包下的原子类AtomicInteger,以下的代码演示了使用AtomicInteger来包装num变量的方式:public class Test {

private static CountDownLatch countDownLatch = new CountDownLatch(1000);

private static AtomicInteger num = new AtomicInteger();

public static void main(String[] args) {

ExecutorService executor = Executors.newCachedThreadPool();

for (int i = 0; i < 1000; i++) {

executor.execute(() -> {

try {

num.getAndIncrement();

} catch (Exception e) {

e.printStackTrace();

} finally {

countDownLatch.countDown();

}

});

}

try {

countDownLatch.await();

} catch (InterruptedException e) {

e.printStackTrace();

}

executor.shutdown();

System.out.println(num);

}

}

多次运行上面的代码,结果都为1000。

版权声明:该文观点仅代表作者本人。处理文章:请发送邮件至 三1五14八八95#扣扣.com 举报,一经查实,本站将立刻删除。