java多线程:线程同步synchronized(不同步的问题、队列与锁),死锁的产⽣和解决
0、不同步的问题
并发的线程不安全问题:
多个线程同时操作同⼀个对象,如果控制不好,就会产⽣问题,叫做线程不安全。
我们来看三个⽐较经典的案例来说明线程不安全的问题。
0.1 订票问题
例如前⾯说过的黄⽜订票问题,可能出现负数或相同。
0.2 银⾏取钱
再来看⼀个取钱的例⼦:
/*
模拟⼀个账户
*/
class Account{
int money;
String name;
public Account(int money, String name) {
< = money;
this.name = name;
}
}
/*
模拟取款机,⽅便设置名字,继承Thread⽽不是实现Runnable
*/
class Drawing extends Thread{
Account account;
int outMoney;//取出去了多少钱
int outTotal;//总共取到了多少钱
public Drawing(Account account, int outMoney,String name) {
super(name);
this.account = account;
this.outMoney = outMoney;
}
@Override
public void run() {
< -= outMoney;
outTotal += outMoney;
System.out.Name() + "---账户余额为:" + );
System.out.Name() + "---总共取到了:" + outTotal);
}
}
然后我们写个客户端调⽤⼀下,假设两个⼈同时取钱,操作同⼀个账户
public class Checkout {
public static void main(String[] args) {
Account account = new Account(200000,"礼⾦");
Drawing you = new Drawing(account,8000,"你");
Drawing wife = new Drawing(account,300000,"你⽼婆");
you.start();
wife.start();
}
}
运⾏起来,问题就会出现。
每次的结果都不⼀样,⽽且,这样肯定会把钱取成负数,显然这是⾮法的(嘻嘻),⾸先逻辑上需要修改,当钱少于 0 了就应该退出,并且不能继续取钱的动作了。按照这个思路,加上⼀个判断呢?
if ( < outMoney){
System.out.println("余额不⾜");
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
可是即便是这样,发现还是会出现结果为负的情况,⽆法保证线程安全。
0.3 数字递增
还有⼀个经典的例⼦,那就是对于直接计算迭代过慢,⽽转为多线程。
⼀个数字 num ,开辟⼀万个线程对他做 ++ 操作,看结果会是多少。
public class AddSum {
private static int num = 0;
public static void main(String[] args) {
for (int i=0; i<=10000; i++){
new Thread(()->{
num++;
}).start();
}
System.out.println(num);
}
}
每次运算的结果都不⼀样,⼀样的是,结果永远 < 10000 。
或者⽤给 list ⾥添加数字来测试:
List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
System.out.println(list.size());
⼀样的结果。
线程不安全的问题如何解决呢?
⼀、同步(synchronized)
1.1 问题出现的原因
从前⾯的介绍⾥,我们总结出会出现同步问题的情况,也就是并发三要素:多个线程、同时操作、操作同⼀个对象。另外,操作的特点是:操作类型为修改。这个时候会产⽣并发的问题,线程安全问题。
1.2 解决⽅案
确保线程安全,第⼀就是排队。只要排队,那么不管多少线程,始终⼀个时间点只会有⼀个线程在执⾏,就保证了安全。
不过排队会有⼀个问题:怎么直到轮到我了呢,也就是怎么知道排在前⾯的线程执⾏完了呢?
现实⽣活中,可能会⽤类似房卡的形式,前⼀个⼈把卡交还了,才会有后⾯的⼈有机会⼊住。这就是锁。
利⽤队列 + 锁的⽅式保证线程安全的⽅式叫线程同步,就是⼀种等待机制,多个同时访问此对象的线程进⼊这个对象的等待池形成队列,前⾯的线程使⽤完毕后,下⼀个线程再使⽤。
锁机制最开始在 java ⾥就是⼀个关键字 synchronized(同步),属于排他锁,当⼀个线程获得对象的排他锁,独占资源,其他线程必须等待,使⽤后释放锁即可。
按照这种思路,可以想象到这种保证安全⽅式的弊端,也就是早期的 synchronized 存在的问题:
⼀个线程持有锁会导致其他所有需要这个锁的线程挂起;
多线程竞争下,加锁、释放锁导致耗时严重,性能问题;
⼀个优先级⾼的线程等待⼀个优先级低的线程的锁释放,会使得本应该的优先级倒置,引起性能问题。
另外,Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来⽤户态和内核态的切换,从⽽增加系统性能开销。因此,在锁竞争激烈的情况
下,Synchronized 同步锁在性能上就表现得⾮常糟糕,它也常被⼤家称为重量级锁。
但是 jdk 6 之后有了很强的改进,这个内容待更新,留个坑。
⼆、同步关键字的⽤法
2.1 同步⽅法
synchronized ⽅法控制对成员变量或者类变量对象的访问,每个对象对应⼀把锁。写法如下:
public synchronized void test(){
//。。。
}
如果修饰的是具体对象:锁的是对象;
如果修饰的是成员⽅法:那锁的就是 this ;
如果修饰的是静态⽅法:锁的就是这个对象.class。
每个 synchronized ⽅法都必须获得调⽤该⽅法的对象的锁才能执⾏,否则所属的这个线程阻塞,⽅法⼀旦执⾏,就独占该锁,直到从该⽅法返回时,锁释放。
同步⽅法的写法代码,以上⾯的取钱案例历的取钱类为例,如果直接在提款机的操作,把 run ⽅法或者⾥⾯的内容提出来变成 test ,加上 synchronized 修饰:
@Override
public void run() {
test();
}
public synchronized void test(){
//内容都不变
}
会发现,仍然出现了负数。锁定失败。
分析:
我们认为在 test ⽅法⾥进⾏的对象修改,所以把他锁上就好了,但是对于这个类,这个提款机类来说,test ⽅法是成员⽅法,因此锁的对象实际上是 this ,也就是提款机。
但我们的初衷,要线程锁的资源应该是 Account 对象,⽽不是提款机对象。
2.2 同步块
除了⽅法,synchronized 还可以修饰块,叫做同步块。
synchronized 修饰同步块的⽅式是:
synchronized (obj){
//...
}
其中的 obj 可以是任何对象,但是⽤到它,肯定是设置为那个共享资源,这个 obj 被称为同步监视器。同步监视器的作⽤就是,判断这个监视器是否被锁定(是否能访问),从⽽决定是否能执⾏其中的代码。
java的花括号中内容有以下⼏种:
⽅法⾥⾯的块:局部块。解决变量作⽤域的问题,快速释放内存(⽐如⽅法⾥⾯再有个for循环,⾥⾯的变量);
类层的块:构造块。初始化信息,和构造⽅法是⼀样的;
类层的静态块:静态构造快。最早加载,不是对象的信息,⽽是类的信息;
⽅法⾥⾯的同步块:监视对象。
第四种就是我们这⾥学习的同步块。
注意,如果是同步⽅法⾥,没必要指定同步监视器,因为同步⽅法的监视器已经是 this 或者 .class。
⽤同步块的⽅式对提款机问题进⾏修改:
public void test(){
synchronized(account){
//内容不变
}
}
也就是加上对 account 的监视器,锁住这个对象。这样运⾏结果就正确了。
这种做法效率不⾼,因为虽然对 account 上了锁,但是每⼀次都要把整个流程⾛⼀遍,⽅法体的内容是很多的,另外,每次加锁与否,都是性能的消耗,进⼊之后再出来,哪怕什么也不做,也是消耗。
其实,我们可以在加锁的前⾯再加⼀重判断,那么之后就没必要再进⾏上锁的过程了。
public void test(){
if ( ==0 ){
return;
}
synchronized(account){
}
}
就是这样的⼀个代码,在并发量很⾼的时候,往往可以⼤⼤提⾼效率。
对于上⾯的 10000 个线程的加法那个问题,我们也可以通过 synchronized 加锁,来保证结果的正确性。
(但是 synchronized 修饰的要是引⽤类型,所以直接对 int num 加锁不⾏,⼀般直接使⽤专门提供的原⼦类)
list 的⾥加数字的测试:
List<String> list = new ArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
synchronized (list){
list.add(Thread.currentThread().getName());
}
}).start();
}
Thread.sleep(2000);
System.out.println(list.size());
main⽅法,下⾯的print语句,这些都是线程,所以可能上⾯还没有操作的时候,就已经输出了,为了⽅便观察,我们在最后输出之前先让main线程休眠⼀会,再看⾥⾯add的结果是否正确。
tips:对于容器的操作,Java的urrent包⾥也直接提供了对应的安全容器CopyOnWriteArrayList。
CopyOnWriteArrayList<String > list1 = new CopyOnWriteArrayList<>();
for (int i=0; i<10000; i++){
new Thread(()->{
list1.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(2000);
System.out.println(list1.size());
2.3 问题
synchronized 块太⼩,可能锁不住,安全性⼜不⾏了,锁的⽅法太⼤,⼜效率会降低,所以要很注意控制范围。
⽽且,还有类似于单例模式⾥ Double-Check 写法针对的问题,有时候⼀重锁性质不够,两重锁仍然不够保证安全。
三、线程同步问题应⽤⽰例
3.1 快乐影院
电影院买票。
/**
* 快乐影院
*/
public class HappyCinema {
public static void main(String[] args) {
Cinema cinema = new Cinema(20, "万达");
new Thread(new Customer(cinema,2)).start();
new Thread(new Customer(cinema,1)).start();
}
}
/**
* 电影院,提供订票⽅法
*/
class Cinema{
int available;
String name;
public Cinema(int available, String name) {
this.available = available;
this.name = name;
}
//提供购票⽅法
public boolean bookTickets(int seats){
System.out.println("可⽤位置为:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
}
/**
* 顾客,有多个顾客,模仿多线程
*/
class Customer implements Runnable{
Cinema cinema;
int seats;
//顾客创建的时候带上要预定的作为+订哪个影院
public Customer(Cinema cinema, int seats) {
this.cinema = cinema;
this.seats = seats;
}
@Override
public void run() {
boolean flag = cinema.bookTickets(seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"买了 "+seats+" 张票");
}else{
System.out.println("出票失败,"+Thread.currentThread().getName()+"买票,但位置不⾜ ");
}
}
}
对于⼀个电影院的票:available 资源来说,多个线程访问,是需要同步的,否则就会出现不安全的问题。解决:
@Override
public void run() {
synchronized (cinema){
//。。。
}
}
}
3.2 快乐影院进阶
影院票的时候不是简单计数,是可以选座位的,我们修改代码,具体到某⼀个座位号的预定。
将 int 座位数⽬改成 List,那么购票⽅法改动如下:
public boolean bookTickets(List<Integer> seats){
System.out.println("可⽤位置为:" + available);
List<Integer> copy = new ArrayList<>(available);
//相减
//判断改变后
if (available.size() != copy.size() + seats.size() ){
return false;
}
available = copy;
return true;
}
其他地⽅只需要做简单的修改,在调⽤的时候传⼊⼀个构造好的 list 即可,这个时候再来看:
如果两个顾客同时订票的位置冲突
可以看到完成了同步。
3.3 ⽕车票
还是类似于订票,因为上⾯电影院的部分我们都使⽤同步块的⽅式锁定某个对象,这⾥使⽤同步⽅法来加深上锁的理解。
模仿第⼀种电影院订票的初始不加锁写法。
public class Happy12306 {
public static void main(String[] args) {
Railway railway = new Railway(20, "京西G12138");
new Thread(new Passenger(railway,2)).start();
new Thread(new Passenger(railway,1)).start();
java单例模式双重锁}
}
/**
* 铁路系统,提供订票⽅法
*/
class Railway{
int available;
String name;
public Railway(int available, String name) {
this.available = available;
this.name = name;
}
//提供购票⽅法
public boolean bookTickets(int seats){
System.out.println("可⽤位置为:"+available);
if (seats > available){
return false;
}
available -= seats;
return true;
}
}
/**
* 顾客,有多个顾客,模仿多线程
*/
class Passenger implements Runnable{
Railway railway;
int seats;
public Passenger(Railway railway, int seats) {
this.railway = railway;
this.seats = seats;
}
@Override
public void run() {
boolean flag = railway.bookTickets(seats);
if (flag){
System.out.println("出票成功,"+Thread.currentThread().getName()+"买了 "+seats+" 张票");
}else{
System.out.println("出票失败,"+Thread.currentThread().getName()+"买票,但位置不⾜ ");
}
}
}
现在开始给⽅法加锁,考虑这个问题:
本来的 run ⽅法写了同步块对⼀个资源加锁,这个资源是票所在的铁路系统(上⼀个例⼦的电影院);
所以如果锁 run ⽅法,我们前⾯说过的,锁成员⽅法相当于锁的 this,也就是锁了乘客类,是没有⽤的,因为被修改的资源不在这⾥;
应该将这个⽅法放到铁路系统类⾥,然后对这个⽅法上锁。
这样会带来新的问题,模拟多个线程的线程体应该来源于乘客,不能是铁路系统,所以乘客类也要继续修改,继承 Thread 类,本⾝作为⼀个代理,去到⽬标接⼝的实现类:铁路系统,然后start。
public class Happy12306 {
public static void main(String[] args) {
Railway railway = new Railway(5, "京西G12138");
new Passenger(5,railway,"乘客B").start();
new Passenger(2,railway,"乘客A").start();
}
}
/**
* 铁路系统,提供订票⽅法,本⾝就是⼀个线程,
*/
class Railway implements Runnable{
int available;

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。