IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 数据结构与算法 -> 面试必备系列JUC(6)--八锁详解 -> 正文阅读

[数据结构与算法]面试必备系列JUC(6)--八锁详解


前言

最近有读者在后台私信,让我讲讲java里常见的锁,第一次被读者提要求,还是很高兴的,查阅了一些资料,尽量将常见的锁的概念讲的清清楚楚,希望读者读后,能对8锁有个清晰的认知!
今天给大家介绍我一个很有趣的好友,秃头,干瘦,可能喜欢女生的广叔!!
长相一直不变~(认识五年了,颜值一直在40岁上下)

一、锁总述

广叔:你知道java中常见的锁吗?

令狐冲:当然知道,话说当年java当年创造之后,有人想把。。。巴拉巴拉。。

广叔:停,知道就知道,不知道就不知道,别装逼!

令狐冲:额,你和之前一样,时代的道德之眼。。。

广叔:你看看这个我从大厂偷来的图,不要告诉别人~

令狐冲:这图无敌了,将java里面涉及到的所有锁都涵盖了。

广叔:美团画的,下面我给你仔细讲讲,我希望你认真听。

令狐冲:我~~~

二、乐观锁 VS 悲观锁

2.1 基本概念

广叔:乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。

令狐冲:那你先给我讲讲基本概念呗,我这个也不知道~

广叔:对于同一个数据的并发操作,乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。 因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据,如果别人修改了数据则放弃操作,否则执行操作。

广叔:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。

2.2 实现方式

令狐冲:咦?我有个疑问,那不加锁,又叫锁,那乐观锁咋实现的?

广叔:乐观锁在Java中是通过使用无锁编程来实现,**最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。**你上次不是和小尼姑仪琳讲过了吗?重色轻友!!

令狐冲:额,尴尬,我想起来了,其实朋友对我也很重要的~

广叔:CAS算法我就不讲了,你自己去翻翻之前的文章,除了CAS,版本号机制也可以用来实现乐观锁。

广叔:版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。

令狐冲:这个之前讲JUC下的原子包 atomic的时候,我也讲了。。。

广叔:行吧,顺便说一句,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。

令狐冲:概括出来就一句话:只要能达到唯一标识的目的就行呗

悲观锁

乐观锁

2.3 优缺点和适应场景

令狐冲:广叔,你说这两种锁的优劣性怎么样呢?

广叔:乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景~,我就从功能限制和竞争激烈程度这两个方面来给你分别说说吧。

广叔:从功能限制这个角度来说, 与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。

广叔:从竞争激烈程度来说,当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。

广叔:当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。

令狐冲:其实,你也就比我厉害了一丢丢而已~,我概括下:
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

广叔:最后看个小例子吧。

// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
	// 操作同步资源
}
// ReentrantLock
//需要保证多个线程使用的是同一个锁
private ReentrantLock lock = new ReentrantLock();
public void modifyPublicResources() {
	lock.lock();
	// 操作同步资源
	lock.unlock();
}

// ------------------------- 乐观锁的调用方式 -------------------------
//需要保证多个线程使用的是同一个AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();  
atomicInteger.incrementAndGet(); //执行自增1

三、公平锁 VS 非公平锁

3.1 基本概念

令狐冲:广叔你给我说说公平锁呗~~

广叔:公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。(类似于排队打疫苗,不能插队,不允许插队!)

令狐冲:那它有啥特点吗?

广叔:公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

令狐冲:太装B了,讲的太抽象了,活该你秃头~

广叔:那我给你举个例子,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。

令狐冲:晓得了,那你再说说啥是非公平锁?路见不平一声吼那种吗?

广叔:别闹,非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

令狐冲:那这种不守规则的锁,为啥还有人用呢?

广叔:额,你想多了,非公平锁用的很多,非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

广叔:算了,给你举个例子吧,还是打水的例子,但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。

3.2 源码解析

令狐冲:那有没有实际点的例子嘛?

广叔:有,肯定有,其实在你经常使用的ReentrantLock中就有相关公平锁,非公平锁的实现了,ReentrantLock里面有一个内部类Sync,Sync继承AQS,添加锁和释放锁的大部分操作实际上都是在Sync中实现的。 它有公平锁FairSync和非公平锁NonfairSync两个子类。

广叔:ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

广叔:下面我们来看一下公平锁与非公平锁的加锁方法的源码:

令狐冲:我只能看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。

广叔:非常正确,再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。

令狐冲:哦哦,这样我就完全明白了,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

四、可重入锁 VS 非可重入锁

4.1 基本概念

令狐冲:那啥是可重入锁呀?

广叔:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。 Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

广叔:看个小例子吧~

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

广叔:在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

令狐冲:我懂你的意思了,就是如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。(相互牵制)

4.2 可重入锁的原理

令狐冲:为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?

广叔:我先给你举个例子,还是打水的例子吧~

广叔:有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。

广叔:如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。

4.3 代码解析

令狐冲:之前听说过ReentrantLock和synchronized都是重入锁,那能不能通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁?

广叔:可以的,首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。(AQS之后的文章会详细介绍)

广叔:当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0 表示没有其他线程在执行同步代码,则把status置为1, 当前线程开始执行。如果status != 0, 则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

广叔:释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放

令狐冲:写两段代码呀,你这样子说话没有证据,和流氓有啥区别?

广叔:我连女生都不喜欢,怎么就耍流氓了?你看下这个例子吧:

令狐冲:ok,知道咋回事了~~~

五、独占锁和共享锁

5.1 基本概念

广叔:你问了我这么多,那你知道啥是独占锁和共享锁吗?

令狐冲:独占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

令狐冲:共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

广叔:是的,独占锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

5.2 底层原理

令狐冲:这个AQS很强大呀,下次得问问田伯光。

广叔:那我这次先通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独占锁和共享锁。先看下ReentrantReadWriteLock的部分源码:

再详细看下ReadLock类和WriteLock类:

广叔:我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。 再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

令狐冲:我发现在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,这是说读锁和写锁的加锁方式都一样?

广叔:这个还真的不一样,读锁是共享锁,写锁是独占锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以Reentrant Read WriteLock的并发性相比一般的互斥锁有了很大提升。

令狐冲:那读锁和写锁的具体加锁方式有什么区别呢?

广叔:在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。

广叔:**在独占锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。**如下图所示:

广叔:了解了概念之后我们再来看代码,先看写锁的加锁源码:

protected final boolean tryAcquire(int acquires) {
	Thread current = Thread.currentThread();
	int c = getState(); // 取到当前锁的个数
	int w = exclusiveCount(c); // 取写锁的个数w
	if (c != 0) { // 如果已经有线程持有了锁(c!=0)
    // (Note: if c != 0 and w == 0 then shared count != 0)
    // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败
		if (w == 0 || current != getExclusiveOwnerThread()) 
			return false;
	   // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
		if (w + exclusiveCount(acquires) > MAX_COUNT)    
      throw new Error("Maximum lock count exceeded");
		// Reentrant acquire
    setState(c + acquires);
    return true;
  }
  // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;
  //或者如果通过CAS增加写线程数失败也返回失败。
  if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) 
		return false;
    // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者
	setExclusiveOwnerThread(current); 
	return true;
}

令狐冲:哎呀,这代码让人看的头大,你给我讲讲呗~~

广叔:细节我就不讲了,tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。

令狐冲:哦哦,那我就知道了,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。

广叔:理解的很到位,写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。

令狐冲:那再看看接着是读锁的代码呗

protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
     // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;                                  
    int r = sharedCount(c);
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) {
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

广叔:可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。

令狐冲:这就是读写锁能实现读读的过程共享,而读写、写读、写写的过程互斥的根本原因吧。

广叔:是的,最后给你看下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:

广叔:我们发现在ReentrantLock虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。

令狐冲:哦哦,所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。

六、自旋锁VS适应性自旋锁

6.1 基本概念

广叔: 你给我说说自旋锁的概念呗~

令狐冲:好呀,介绍这个概念之前,我i先说说一个基本现象。我们都知道,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

广叔:是的,这个问题很常见,也让人很纠结。在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。

令狐冲:对了,如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

广叔:你这种想法很奇妙呀,继续说说~

令狐冲:而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

6.2 基本原理

令狐冲:你再看看下面自选的详细过程:

广叔:画的挺好的,那自旋锁有啥缺点吗?

令狐冲:当然了,首先,它不能代替阻塞。其次,自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

广叔:那你说说它的实现原理呗~~

令狐冲:自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

广叔:那自适应自旋锁是啥?

令狐冲:自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

令狐冲:**自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。**如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

广叔:回答的还行吧,你好好总结学习下今天说的内容,下次面试是要考的~~

===================================================
字节内推:
字节内推〉字节校招开启。简历砸过来!!!!!!!
200多个岗位,地点:北京 上海 广州 杭州 成都 深圳。。
有问题可以直接在公众号中回复,必回答!!!

字节内推码:B1RHWFK
官网校招简历投递通道:https://jobs.toutiao.com/campus/m/position?referral_code=B1RHWFK

===================================================
微信公众号:猿侠令狐冲
公众号定期更新文章!!

  数据结构与算法 最新文章
【力扣106】 从中序与后续遍历序列构造二叉
leetcode 322 零钱兑换
哈希的应用:海量数据处理
动态规划|最短Hamilton路径
华为机试_HJ41 称砝码【中等】【menset】【
【C与数据结构】——寒假提高每日练习Day1
基础算法——堆排序
2023王道数据结构线性表--单链表课后习题部
LeetCode 之 反转链表的一部分
【题解】lintcode必刷50题<有效的括号序列
上一篇文章      下一篇文章      查看所有文章
加:2021-09-07 11:04:32  更:2021-09-07 11:06:04 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/26 0:43:47-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码