最近工作上需要使用java完成高并发的服务器后台设计,因此对此作了一些研究,于是想把研究的心得,总结,经验写出来与大家分享,顺便巩固自己的认知。java通常用来开发大型网站,特别是用来开发应对高并发的后台服务器,例如淘宝就是依赖java后台来满足每天面临的海量数据请求。
java在应对高并发上形成了一系列成熟的设计思想以及应用框架,掌握这些知识能大大扩宽一个技术人员的择业范围和技术实力,在未来十年内,在处理海量数据请求和高并发需求上,java的统治地位不会有太大的动摇。
掌握高并发海量数据处理的技术能力会使你在市场上非常吃香,如果你找后台开发的职位,你会发现“高并发”,“海量数据处理”几乎都是这类职位的必备要求。高并发的处理本质上来说,就是把海量请求分发到足够多的服务器集群上,也就是采用分而治之的原则,“海量请求”经过足够密度的切割后,所得的每一小块数量没那么大,并且服务器的处理能力又足够强,那么应对高并发情景自然没有太大问题。
由此“并行计算”就是处理高并发的核心所在。然而并行计算本身需要处理的技术问题也足够复杂,这次我们看一个常见棘手问题,那就是信息共享问题。假设我们在服务器上有多个线程并行处理数据或请求,线程的运行逻辑受到一系列共享变量的影响,假设线程A,B同时需要读取变量C,A,B可能运行在不同的处理器上,C可能存储在另一台机器上,线程A更改了C的值后,我们如何确保线程B能读取到C最新的最新值?这个不是一个简单容易处理的问题, 我们先先看一个例子:
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关键字还涉及到字节码的重排序问题。程序在运行时,代码的执行顺序并非像我们编写的那样一条条从上到下,编译器或虚拟机为了优化执行速度,有可能会在不影响程序逻辑的情况下先执行下面的代码,然后在执行上面的代码,例如:
int h = 10; //1
int w; //2
w = 15; //3
int a = h * w; //4
通常我们会认为上面代码的执行次序是从上到下,也就是1,2,3,4.实际执行时的次序有可能是2,3,1,4,次序的改变通常不会改变逻辑结构,但是在某些特定情况下也会带来意外,意外通常来自单子模式,例子如下:
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变量进行读写时也容易出问题,例如下面的例子:
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类能实现整形类型加法操作的原子性,于是把上面代码替换如下:
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多线程高并发模型原理我们在后续章节继续讨论。