一、共享性
数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。但是,在多线程编程中,数据共享是不可避免的。最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中数据,即使是在主从的情况下,访问的也同一份数据,主从只是为了访问的效率和数据安全,而对同一份数据做的副本。我们现在,通过一个简单的示例来演示多线程下共享数据导致的问题:
上述代码通过10个线程同时对次数进行加一操作,每个线程执行100次,正常情况下,应该输出1000。不过,如果你运行上面的程序,你会发现结果却不是这样,因为对共享变量操作,在多线程环境下很容易出现各种意想不到的的结果。下面是某次的执行结果(每次运行的结果不一定相同,有时候也可能获取到正确的结果):
二、互斥性
互斥性是指同时只允许一个访问者访问某个资源。我们通常允许多线程同时对数据进行读操作,但是同一时间内只允许一个线程对数据进行写操作,这就是锁中的共享锁和互斥锁(排他锁),也叫读锁和写锁。在并发编程中,我们很多时候都必须要考虑线程安全问题,涉及到共享数据的写操作时,就必须要保证互斥性,java中提供了多种机制来保证互斥性,最常用的就是使用synchronized关键字或者lock锁,此时我们使用synchronized关键字对方法加锁后,无论你怎么执行,最终结果都会是1000:
三、原子性
原子性就是指对数据的操作是一个独立的、不可分割的整体。通俗点说,就是一次操作、是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。但是很多操作不能通过一条指令就完成。例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。
还比如,我们经常使用的整数 i++ 的操作,其实需要分成三个步骤:(1)读取整数 i 的值;(2)对 i 进行加一操作;(3)将结果写回内存。
对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的Synchronized或Lock都可以实现。除了锁以外,还有一种方式就是CAS算法(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。
四、可见性
从这个图中我们可以看出,每个线程都有一个自己的工作内存,对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。
五、有序性
为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:
(1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序。在数据不存在依赖性的前提下,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序。内存系统的重排序指的是:处理器使用缓存和读/写缓冲区造成的加载和存储可能是乱序执行。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。