java高并发架构设计原理:java的内存模型,volatile和线程数据安全

2021-04-21 10:29:11 浏览数 (1)

最近工作上需要使用java完成高并发的服务器后台设计,因此对此作了一些研究,于是想把研究的心得,总结,经验写出来与大家分享,顺便巩固自己的认知。java通常用来开发大型网站,特别是用来开发应对高并发的后台服务器,例如淘宝就是依赖java后台来满足每天面临的海量数据请求。

java在应对高并发上形成了一系列成熟的设计思想以及应用框架,掌握这些知识能大大扩宽一个技术人员的择业范围和技术实力,在未来十年内,在处理海量数据请求和高并发需求上,java的统治地位不会有太大的动摇。

掌握高并发海量数据处理的技术能力会使你在市场上非常吃香,如果你找后台开发的职位,你会发现“高并发”,“海量数据处理”几乎都是这类职位的必备要求。高并发的处理本质上来说,就是把海量请求分发到足够多的服务器集群上,也就是采用分而治之的原则,“海量请求”经过足够密度的切割后,所得的每一小块数量没那么大,并且服务器的处理能力又足够强,那么应对高并发情景自然没有太大问题。

由此“并行计算”就是处理高并发的核心所在。然而并行计算本身需要处理的技术问题也足够复杂,这次我们看一个常见棘手问题,那就是信息共享问题。假设我们在服务器上有多个线程并行处理数据或请求,线程的运行逻辑受到一系列共享变量的影响,假设线程A,B同时需要读取变量C,A,B可能运行在不同的处理器上,C可能存储在另一台机器上,线程A更改了C的值后,我们如何确保线程B能读取到C最新的最新值?这个不是一个简单容易处理的问题, 我们先先看一个例子:

代码语言:javascript复制

public class java_model implements Runnable{
    private  String str;
      void setStr(String str) {this.str = str;}

      public void run() {
        while (str == null);
        System.out.println(str);
      }

      public static void main(String[] args) throws InterruptedException {
          java_model delay = new java_model();
        new Thread(delay).start();
        Thread.sleep(1000);
        delay.setStr("Hello world!!");
      }


}

运行上面代码,你会发现程序会陷入死锁状态,原因在于while(str == null);这条语句一直在执行,问题在于在main中,我们已经使用setStr设置了str变量的值,因此语句while(str ===null)不应该一直执行下去,如果我们给private String str改成private volatile String str,那么程序就会打印出”Hello World!”后顺利终结,为何会出现这种奇怪的现象呢,这就涉及到java的内存模型:

在java虚拟机中,每个线程有自己的本地缓存,不同线程不同读取其他线程的缓存。与此同时虚拟机还有全局缓存,也就是上图对应的L3 cache,全局变量存储在全局缓存中,当线程需要读取全局变量时,它会将变量在全局缓存中的信息拷贝到本地缓存,以后读取时它会直接从本地缓存读取,由此能大大提高信息读取的效率。

这意味着变量str其实由多份拷贝,每个线程一份,同时全局内存中还有一份。这带来一个非常严重的问题,那就是数据根本不同步,线程1修改了全局变量后,线程2根本就不知道,如此程序运行就会出现严重错误。解决这个问题的办法就是迫使线程在读取数据时,每次都必须从全局内存将变量的信息拷贝到本地缓存,写入数据时必须立马将写入的数据更新到全局缓存中,如此一来全局变量被线程1修改后,线程2能尽快看到,实现这个动作就需要volatile关键字。

其次volatile关键字还涉及到字节码的重排序问题。程序在运行时,代码的执行顺序并非像我们编写的那样一条条从上到下,编译器或虚拟机为了优化执行速度,有可能会在不影响程序逻辑的情况下先执行下面的代码,然后在执行上面的代码,例如:

代码语言:javascript复制
int h = 10; //1
int w; //2
w = 15; //3
int a = h * w; //4

通常我们会认为上面代码的执行次序是从上到下,也就是1,2,3,4.实际执行时的次序有可能是2,3,1,4,次序的改变通常不会改变逻辑结构,但是在某些特定情况下也会带来意外,意外通常来自单子模式,例子如下:

代码语言:javascript复制

public  class Singleton{
    private static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }

    return instance;
    }
}

这种代码在多线程条件下运行时很容易出问题,原因在于前面提到的指令重排序。原因在于语句instance = new Singleton();在顺序执行时,该语句会先分配内存,调用类的构造方法,然后将内存地址分配给变量instance。但重排序发生时语句的执行有可能变成先分配内存,然后把内存地址分配给变量instance,然后在执行初始化函数。因此在多线程时,如果有一个线程执行了该语句,并执行了第2步,此时instance变量不再为null, 这时另一个线程同时调用了getInstance()函数,于是它就会得到一个初始化函数没有被调用的实例对象。

为了避免这种重排序问题就可以使用volatile关键字,将语句变成private volatile static Singleton instance = null;就能避免上面描述的问题。然而使用volatile还有问题,那就是它不能保证操作的原子性,例如a 这类操作在多线程下即使变量用valotile修饰也同样出问题。

因为volatile修饰的关键字可以保证其信息及时刷新,但a 这种操作等价于a = a 1,如果a被volatile修饰,那么在执行a = a 1时,它会先把a的变量从主存读入线程的本地缓存,然后更改本地缓存的值,接着把更改后的结果重新写回到主存。在多线程情况下,线程1执行a 时会将a的值从主存读入,同一时间线程2也执行a ,同样也把a的值从主存读入,注意此时线程2读入的a值还没有被线程1更新,于是在多线程同时对volatile变量进行读写时也容易出问题,例如下面的例子:

代码语言:javascript复制

public class VolatileForPlusPlus {
    public static volatile int a = 0;
    public static void main(String[] strs) throws Exception {
        for (int i = 0; i < 100; i  ) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j  ) {
                        a  ; //这里有问题
                    }
                }
            }).start();
        }

        Thread.sleep(3000); //等待所有线程启动
        System.out.print(a); //a的值很可能不会是10000 * 100
    }
}

在我电脑上输出结果为956626,出现这个结果的原因就是因为a 操作其实蕴含了好几步指令,无法实现原子化操作。java提供了保证若干计算操作实现原子性的接口,例如AtomicInteger类能实现整形类型加法操作的原子性,于是把上面代码替换如下:

代码语言:javascript复制
import java.util.concurrent.atomic.AtomicInteger;

public class VolatileForPlusPlus {
    public static AtomicInteger  a = new AtomicInteger(0);
    public static void main(String[] strs) throws Exception {
        for (int i = 0; i < 100; i  ) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j  ) {
                        a.incrementAndGet(); //这里有问题
                    }
                }
            }).start();
        }

        Thread.sleep(3000); //等待所有线程启动
        System.out.print(a); //a的值很可能不会是10000 * 100
    }
}

保证操作的原子性后就能得到准确结果,更多java多线程高并发模型原理我们在后续章节继续讨论。

0 人点赞