
由于现在软硬件设施的快速升级,多核处理器的普及和分布式系统的广泛应用,使得并发编程已成为构建高性能、高响应性应用程序的关键技术。
并发编程核心就是多线程并发执行多个任务,充分利用多核处理器优势,以提高软件程序的性能,随着多核处理器的这种优势性能提升的同时,也带来了线程安全和数据一致性等问题。
在多线程并发中,如何确保数据的共享、竞争、死锁、安全这些程序问题?我们就不得不说一说多线程并发编程中的三大特性了。
说到线程安全那我们就不得不了解一下并发编程的三大特性:可见性、原子性、有序性。
只有深入了解了三大特性,才能明白在线程安全时需要注意哪些方面。
可见性是说一个线程对共享变量的修改能够被其他线程及时看到。
比如买票软件,共100张票,A线程来抢走了一张,那么B线程看到的必须就是99张。
我们看一个案例,当线程1启动后,进入循环。当线程2启动后,赋值flag=false,此时线程1中的循环一直不结束。说明flag在线程2中虽然修改了值,但这个修改对线程1是不可见的。
public class T5 {
public static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
}
System.out.println("线程1结束");
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
flag = false;
System.out.println("线程2设置flag为false");
});
t2.start();
}
}关键字: volatile 的声明代表了当前变量对于线程间是可见的、有序的。
volatile是jvm提供的一种轻量级的同步机制,它可以确定一个共享对象的可见性、有序性,但不能支持原子性。也就是说volatile实际上是支持了三大特性的两种特性。
下面的案例,当线程1启动后,进入循环。当线程2启动后,赋值flag=false,此时线程1中的循环结束。说明flag是线程可见的。
public class T5 {
//通过volatile声明变量,确保可见性
public static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
}
System.out.println("线程1结束");
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
flag = false;
System.out.println("线程2设置flag为false");
});
t2.start();
}
}打印结果为

原子性是说对于一个操作,在整个的执行过程中不能被中断,要么完全执行,要么完全不执行。
我们实现一个如果不保证原子性可能存在的问题:首先创建5个线程,每个线程都都执行add()操作。正常情况下我们打印total的时候,值应该是 5000.但实际的结果大家可以看下方的结果图,结果为:3401 。当然,这个3401不是固定的,每一次可能都不一样。
从这个案例中我们也看出来了。total因为volatile的修饰,成为了一个多线程间可见的变量了,但它不是原子性的。
public class T6 {
public static volatile Integer total = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 5; i++) {
new Thread(()->add() ).start();
}
Thread.sleep(1000);
System.out.println(total);
}
public static void add(){
for (int i = 0; i < 1000; i++) {
total++;
}
}
}结果

我们重点分析一下total++;这里,实际上的结果是因为total++ 并非是原子性导致的结果错误。
有序性是说代码在运行时没有按照开发者编写的顺序执行,而是按照编译器优化后的编译结果执行。
我们在写代码的时候,深信代码就是按我所写的顺序,从上往下执行的。比如下面的代码:
public static void main(String[] args) {
int a = 1;
a++;
System.out.print(a);
}上面的代码,打印a的值,肯定就是2 ,这点毋庸置疑,这样可以说明我们的代码确实是从上往下执行的,但真的所有的代码都如此吗?
我们来看下面的这一段代码。
对于单例模式下的instance实例来说,当instance为null时,我们需要实例化。
一个类进行实例化,它需要经过以下的流程,首先jvm先将class文件进行加载(loading);然后链接(linking),链接阶段分为三个步骤,准备(preparation),初始化类中的静态变量赋默认值(static),最后是类的初始化过程(initializing)。
下面的代码正常情况下执行应该为:分配内存空间 - 初始化对象 - 将instance指向分配的内存地址。
但是对于单实例来说,如果这样执行会有问题吗?
分配内存空间 - 将instance指向分配的内存地址 - 初始化对象 。
答案是否定的,对于单实例来说,instance按上面先分配空间,然后直接指向空间,在初始化,不会有任何问题。
所以编译器会在编译的时候,选择这样优化代码。但如果并发的情况下,就会导致线程切换后,发现instance已经指向了空间,已经有数据了,所以不为null,直接返回对象。实际上这个对象还没有初始化。
if(instance == null){
instance = new Singleton();
}
指令重排是指应用在程序执行的过程中,编译器或处理器为了优化性能,可能会对代码的执行顺序进行调整,这种调整在单线程环境下是不会对程序的最终结果有所变动的。然而,当我们处于在多线程环境下,就可能会导致一些问题的产生。

熟悉并了解并发编程的三大特性,对于开发者来说是非常有必要的。只有熟悉了解其特性,我们才能够在并发的场景中,准确的发现并预处理相关的问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。