一、意义
java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查),将会导致数据不准确,相互之间产生冲突,因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。
在具体的Java代码中需要完成以下两个操作:
把竞争访问的资源类变量标识为private;不让外部直接更改
同步修改变量的代码,使用synchronized关键字同步方法或代码。
二、锁的原理
Java中每个对象都有一个内置锁
当程序运行到非静态的synchronized同步方法上时,自动获得与正在执行代码类的当前实例(this实例)有关的锁。获得一个对象的锁也称为获取锁、锁定对象、在对象上锁定或在对象上同步。
当程序运行到synchronized同步方法或代码块时才该对象锁才起作用。
一个对象只有一个锁。所以,如果一个线程获得该锁,就没有其他线程可以获得锁,直到第一个线程释放(或返回)锁。这也意味着任何其他线程都不能进入该对象上的synchronized方法或代码块,直到该锁被释放。
释放锁是指持锁线程退出了synchronized同步方法或代码块。
关于锁和同步,有一下几个要点:
1)、只能同步方法,而不能同步变量和类;
2)、每个对象只有一个锁;当提到同步时,应该清楚在哪个对象上同步
3)、不必同步类中所有的方法,类可以同时拥有同步和非同步方法。同步方法的访问会受到锁的限制,非同步方法可以被多个线程自由访问而不受锁的限制。
4)、如果一个线程在对象上获得一个锁,就没有任何其他线程可以进入(该对象的)类中的任何一个同步方法。
5)、线程睡眠时,它所持的任何锁都不会释放。
6)、线程可以获得多个锁。比如,在一个对象的同步方法里面调用另外一个对象的同步方法,则获取了两个对象的同步锁。
7)、同步损害并发性,应该尽可能缩小同步范围。同步不但可以同步整个方法,还可以同步方法中一部分代码块。
三、线程同步的解决方法
1、synchronized关键字实现同步
即有synchronized关键字修饰的方法。由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。synchronized关键字加锁的原理,其实是对对象加锁,不论你是在方法前加synchronized还是语句块前加,锁住的都是对象整体
public synchronized int getX() {
return x++;
}
当然,同步方法也可以改写为非同步方法,但功能完全一样的,例如:
public int getX() {
synchronized (this) {
return x;
}
}
synchronized也可以修饰静态方法,同步静态方法,需要一个用于整个类对象的锁,这个对象是就是这个类(XXX.class)
public static synchronized int setName(String name){
Xxx.name = name;
}
等价于:
public static String getName(){
synchronized(Xxx.class){
return Xxx.name;
}
}
2、使用特殊域变量(volatile)实现线程同步
• volatile关键字为域变量的访问提供了一种免锁机制;
• 使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新;
• 每次使用它都到主存中进行读取。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。volatile关键字就是提示JVM:对于这个成员变量不能保存它的私有拷贝,而应直接与共享成员变量交互。
• volatile保证了可见性和有序性,但不会提供任何原子操作,它也不能用来修饰final类型的变量
• volatile最适用一个线程写,多个线程读的场合。如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原子变量来代替。
这篇博文讲解的比较清楚:Java之美之线程同步的引入
您只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
1)、对变量的写操作不依赖于当前值。
2)、该变量没有包含在具有其他变量的不变式中。
也就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。更多信息参考以下博文:Java并发编程:volatile关键字解析
例:结合使用 volatile 和 synchronized 实现 “开销较低的读-写锁”
volatile 允许多个线程执行读操作,因此当使用 volatile 保证读代码路径时,要比使用锁执行全部代码路径获得更高的共享度 —— 就像读-写操作一样。
public class CheesyCounter {
private volatile int value;
//读操作,没有synchronized,提高性能
public int getValue() {
return value;
}
//写操作,必须synchronized。因为x++不是原子操作
public synchronized int increment() {
return value++;
}
注:多线程中的非同步问题主要出现在对域的读写上,如果让域自身避免这个问题,则就不需要修改操作该域的方法。用final域,有锁保护的域和volatile域可以避免非同步的问题。
3、使用重入锁(Lock)实现线程同步
在Java5中,专门提供了锁对象,利用锁可以方便的实现资源的封锁,用来控制对竞争资源并发访问的控制,这些内容主要集中在java.util.concurrent.locks 包下面,里面有三个重要的接口Condition、Lock、ReadWriteLock。
类 | 特性 |
---|---|
Condition | Condition 将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。 |
Lock | Lock 实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。 |
ReadWriteLock | ReadWriteLock 维护了一对相关的锁定,一个用于只读操作,另一个用于写入操作。 |
ReentrantLock类是可重入、互斥、实现了Lock接口的锁。
重进入是一种基于per-thread的机制,并不是一种独立的同步方法 。基本实现是这样的:
每个锁关联一个请求计数器和一个占有它的线程,当计数器为0时,锁是未被占有的,线程请求时,JVM将记录锁的占有者,并将计数器增1,当同一线程再次请求这个锁时,计数器递增;线程退出时,计数器减1,直到计数器为0时,锁被释放。
class Bank {
private int account = 100;
//创建一个ReentrantLock实例
private Lock lock = new ReentrantLock();
public int getAccount() {
return account;
}
//这里不再需要synchronized
public void save(int money) {
lock.lock();//获得锁
try {
account += money;
} finally {
lock.unlock();//释放锁
}
}
}
注意:lock一定要显示的unlock(),不然会出现问题
4、Semaphore(信号量)
信号量的意思就是设置一个最大值,来控制有限个对象同时对资源进行访问。因为有的时候有些资源并不是只能由一个线程同时访问的,举个例子,我这儿有5个碗,只能满足5个人同时用餐,那么我可以设置一个最大值5,线程访问时,用acquire() 获取一个许可,如果没有就等待,用完时用release() 释放一个许可。这样就保证了最多5个人同时用餐,不会造成安全问题,这是一种很简单的同步机制。
public static void main(String[] args) {
// 线程池
ExecutorService exec = Executors.newCachedThreadPool();
// 只能5个线程同时访问
final Semaphore semp = new Semaphore(5);
// 模拟20个客户端访问
for (int index = 0; index < 20; index++) {
final int NO = index;
Runnable run = new Runnable() {
public void run() {
try {
// 获取许可
semp.acquire();
System.out.println("Accessing: " + NO);
Thread.sleep((long) (Math.random() * 10000));
// 访问完后,释放
semp.release();
System.out.println("-----------------"+semp.availablePermits());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
exec.execute(run);
}
// 退出线程池
exec.shutdown();
}
当信号量的数量上限是1时,Semaphore可以被当做锁来使用。通过take和release方法来保护关键区域。这就有点类似于lock的功能了,只不过信号灯量可以由一个线程使用,然后由另一个线程来进行释放,而锁只能由同一个线程启动和释放,
5、事件驱动
事件驱动的意思就是一件事情办完后,唤醒其它线程去干另一件。这样就保证:1、数据可见性。在A线程执行的时候,B线程处于睡眠状态,不可能对共享变量进行修改。2、互斥性。相当于上锁,不会有其它线程干扰。常用的方法有:sleep()、wait()、notify()等等。
四、线程同步的问题
如果线程试图进入同步方法,而其锁已经被占用,则线程在该对象上被阻塞。实质上,线程进入该对象的一种池中,必须在那里等待,直到其锁被释放,该线程再次变为可运行或运行为止。
当考虑阻塞时,一定要注意哪个对象正被用于锁定:
1、调用同一个对象中非静态同步方法的线程将彼此阻塞。如果是不同对象,则每个线程有自己的对象的锁,线程间彼此互不干预。
2、调用同一个类中的静态同步方法的线程将彼此阻塞,它们都是锁定在相同的Class对象上。
3、静态同步方法和非静态同步方法将永远不会彼此阻塞,因为静态方法锁定在Class对象上,非静态方法锁定在该类的对象上。
4、对于同步代码块,要看清楚什么对象已经用于锁定(synchronized后面括号的内容)。在同一个对象上进行同步的线程将彼此阻塞,在不同对象上锁定的线程将永远不会彼此阻塞。
五、何时需要同步
在多个线程同时访问互斥(可交换)数据时,应该同步以保护数据,确保两个线程不会同时修改更改它。
对于非静态字段中可更改的数据,通常使用非静态方法访问。
对于静态字段中可更改的数据,通常使用静态方法访问。
如果需要在非静态方法中使用静态字段,或者在静态字段中调用非静态方法,问题将变得非常复杂。
六、Java中封装好的同步和并发工具类
1)、同步容器
Java常用的容器有ArrayList、LinkedList、HashMap等等,这些容器都是非线程安全的。如果有多个线程并发地访问这些容器时,就会出现问题。
所以,Java提供了同步容器供用户使用。在Java中,同步容器主要包括2类:
A、Vector、Stack、HashTable
Vector实现了List接口,Vector实际上就是一个数组,和ArrayList类似,但是Vector中的方法都是synchronized方法,即进行了同步措施。
Stack也是一个同步容器,它的方法也用synchronized进行了同步,它实际上是继承于Vector类。
HashTable实现了Map接口,它和HashMap很相似,但是HashTable进行了同步处理,而HashMap没有。
B、Collections类中提供的静态工厂方法创建的类
Collections类是一个工具提供类。在Collections类中提供了大量的方法,比如对集合或者容器进行排序、查找等操作。更重要的是,在它里面提供了几个静态工厂方法来创建同步容器类
从同步容器的具体实现源码可知,同步容器中的方法采用了synchronized进行了同步,那么必然会影响到程序的执行性能。于是Java提供了性能更优的并发容器
注:我们可以通过Collections的一些方法,使得HashMap这样的非线程安全类变为线程安全的类,如:Collections.synchronizedMap(new HashMap())
2)、并发容器
java.util.concurrent提供了多种并发容器,总体上来说有4类:
Queue类型的BlockingQueue和ConcurrentLinkedQueue
Map类型的ConcurrentMap
Set类型的ConcurrentSkipListSet和CopyOnWriteArraySet
List类型的CopyOnWriteArrayList
3)、原子量
所谓的原子量即操作变量的操作是“原子的”,该操作不可再分,因此是线程安全的。
为何要使用原子变量呢,原因是多个线程对单个变量操作也会引起一些问题。在Java5之前,可以通过volatile、synchronized关键字来解决并发访问的安全问题,但这样太麻烦。
Java5之后,专门提供了用来进行单变量多线程并发安全访问的工具包java.util.concurrent.atomic,其中的类也很简单。