ReentrantReadWriteLock实现了可重入的读锁和写锁,其中读锁是共享锁,写锁是互斥锁。与ReentrantLock类似,ReentrantReadWriteLock也提供了公平锁和非公平锁两种实现,以满足不同的场景。因此,实际在使用时,会涉及到读锁、写锁、公平锁、非公平锁四个不同的概念,这也使得ReentrantReadWriteLock更加复杂一些。

1.核心字段与构造器

    private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync; public ReentrantReadWriteLock() {
this(false);
} public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}

ReentrantReadWriteLock通过ReadLockWriteLock两个内部类分别实现读锁和写锁,并且默认的构造器使用非公平锁。Sync这个内部类同样继承了AbstractQueuedSynchronizer(AQS),排队等候的逻辑都交由AQS实现。接下来在讲解加锁和解锁的逻辑之前,需要明确的一点是,AQS会使用state字段记录锁状态,ReentrantReadWriteLock对该字段的使用更加巧妙,使用state的高16位记录读锁持有的总次数,使用低16位记录写锁持有的总次数,这样使用一个字段即可记录两个状态。因此在下文的源码讲解中,会发现读锁重入时,重入次数需要加2^16-1,写锁重入时只需要加1,同时这也意味着读锁和写锁的重入次数的最大值是2^16-1。接下来分别看一下读锁和写锁的加锁逻辑。

2.读锁的加锁逻辑

    public void lock() {
sync.acquireShared(1);
} //位于AQS
public final void acquireShared(int arg) {
//成功获取读锁返回1,否则返回-1
if (tryAcquireShared(arg) < 0)
//如果获取读锁失败,就执行下面的逻辑加入等待队列
doAcquireShared(arg);
} //位于Sync
protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread();
int c = getState();
//如果写锁已经被占有,并且占有者不是当前线程,则返回-1,即写锁被其他线程占有时不能获取读锁
//从这里可以看出,线程在持有写锁的情况下,可以继续获取读锁
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//获取读锁的重入次数
int r = sharedCount(c);
//如果读锁不需要阻塞,并且读锁的获取数量没有达到最大值(2^16-1),则尝试将读锁的持有数量加1(注意是在高16位加1,SHARED_UNIT=2^16-1),
//如果加1操作能够成功,则表示当前线程成功获取读锁
//注意,公平读锁和非公平读锁的readerShouldBlock()方法逻辑是不一样的
//非公平读锁在等待队列第一个线程请求写锁时会返回true(即在这种情况下非公平锁需要阻塞),其他情况都返回false
//公平读锁会查看当前等待队列中是否有其他线程在等待
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//r == 0表示当前线程是第一个获取读锁的线程,将firstReader指向自己,并初始化firstReaderHoldCount字段
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
//如果firstReader已经指向了自己,就将firstReaderHoldCount加1,表示当前线程作为第一个获取读锁的线程,共获取读锁的次数
firstReaderHoldCount++;
} else {
//cachedHoldCounter记录的是最后一个获取读锁的线程
//使用cachedHoldCounter可以节省在ThreadLocal中的查找操作
HoldCounter rh = cachedHoldCounter;
//如果cachedHoldCounter还没初始化,或者最后一个获取读锁的线程不是当前线程,就从ThreadLocal中查看当前线程对应的HoldCounter
//注意,如果无法在ThreadLocal中查到当前线程的记录,那么就会新建一个HoldCounter加入ThreadLocalMap中,对应的count字段初始化为0
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
//执行到这里,说明rh != null,并且当前线程是最后一个获取读锁的线程,此时更新ThreadLocalMap中的value值
else if (rh.count == 0)
readHolds.set(rh);
//将读锁的持有次数加1
rh.count++;
}
//获取读锁成功,则返回1
return 1;
}
//执行到这里有三种情况:1)readerShouldBlock()返回true,即读锁需要阻塞;2)读锁重入次数已经达到最大值;3)更新state字段失败
//无论哪种情况,都说明当前线程没有成功获取读锁
return fullTryAcquireShared(current);
} //读写锁使用state字段的低16位表示写锁,高16位表示读锁
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; } //获取读锁被持有的次数,数量由state的高16位表示
static int sharedCount(int c) { return c >>> SHARED_SHIFT; } //如果写锁被其他线程占有,或者当前获取读锁的线程需要阻塞,就返回-1,如果获取读锁成功则返回1,其他情况会继续自旋
//上面的注释中讲过,执行到fullTryAcquireShared有三种情况,该方法在自旋中分别处理这三种情况
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
//自旋
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0) {
//如果写锁已经被其他线程占有,则返回-1
if (getExclusiveOwnerThread() != current)
return -1;
} else if (readerShouldBlock()) {
//处理读锁需要阻塞的情况
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
} else {
if (rh == null) {
//获取最后一个获取锁的线程
rh = cachedHoldCounter;
//如果当前线程不是最后一个获取锁的线程,则从ThreadLocalMap中取出HoldCount对象
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
//当前线程没有获取读锁,就将ThreadLocalMap中存的HoldCount清理掉
//因为根据上面的代码逻辑,走到这里的时候,说明当前获取读锁的线程应该阻塞,
//即无法获取读锁,这种情况下需要清理ThreadLocalMap中的记录
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
//读锁获取数量达到最大,抛出异常,这段代码说明读锁的最大重入次数是2^16-1
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//如果成功更新state的值,则表示读锁获取成功,否则会继续自旋
if (compareAndSetState(c, c + SHARED_UNIT)) {
//如果当前线程是第一个持有读锁的线程,就设置firstReader字段
if (sharedCount(c) == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
//从ThreadLocal中查找当前线程对应的HoldCounter对象
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
//更新当前线程持有锁的数量
rh.count++;
//将cachedHoldCounter指向当前线程的HoldCounter对象
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}

tryAcquireShared()方法用到了ThreadLocal来记录当前线程获取的读锁数量,有兴趣的话可以参考ThreadLocal源码探究 (JDK 1.8)了解ThreadLocal的实现细节。公平锁和非公平锁以不同的方式实现了readerShouldBlock()方法,接下来分别讲解公平锁和非公平锁的实现。

  • 非公平锁的readerShouldBlock()实现
    //由名字可以看出,这个方法主要是判断读锁是否应该阻塞
final boolean readerShouldBlock() {
/* As a heuristic to avoid indefinite writer starvation,
* block if the thread that momentarily appears to be head
* of queue, if one exists, is a waiting writer. This is
* only a probabilistic effect since a new reader will not
* block if there is a waiting writer behind other enabled
* readers that have not yet drained from the queue.
*/
return apparentlyFirstQueuedIsExclusive();
} //如果等待队列的第一个线程请求互斥锁(即写锁),则返回true,表示应该阻塞当前的读锁
//由readerShouldBlock()方法的注释了解到,非公平的读锁在发现等待队列头节点请求互斥锁时,
//需要进行阻塞而不是抢锁,是为了避免读请求太多的情况下造成写锁线程饥饿
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
//head != null表示等待队列已初始化,
//h.next != null && !s.isShared()表示队列的头结点请求的是互斥锁
//s.thread != null表示头结点对应的线程还没有开始执行
return (h = head) != null &&
(s = h.next) != null &&
!s.isShared() &&
s.thread != null;
}

对于非公平锁的读锁来说,在发现等待队列中的第一个线程请求写锁时,会主动取消抢锁,是为了避免请求写锁的线程饥饿,这是与ReentrantLock中的非公平锁一个很大的不同。与非公平锁不同的是,公平锁会先查看队列中是否有其他线程在等待。

  • 公平锁的readerShouldBlock()实现
    final boolean readerShouldBlock() {
return hasQueuedPredecessors();
} public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
//只要队列中有其他节点在等候,公平锁就要求当前线程排队等待
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}

至此tryAcquireShared()方法的逻辑已经介绍完了,在该方法返回-1时,表示当前无法获取读锁,就会接着执行doAcquireShared()方法,来看看该方法的源码:

    private void doAcquireShared(int arg) {
//将当前线程构造成节点,放到等待队列的末尾
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
//中断标志
boolean interrupted = false;
for (;;) {
//获取当前线程的前一个节点
final Node p = node.predecessor();
//只有在当前节点是队列中的第一个有效节点时,才会执行下面的语句
if (p == head) {
//尝试获取读锁,获取成功则r>0,失败则r<0
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
//如果线程设置了中断标记,则将当前线程中断
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//当前线程不是队列第一个有效节点,或者获取读锁失败,就阻塞等待
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//线程中断之后,设置interrupted=true,之后代码逻辑会自旋一次,会在for循环的第一个条件语句中用到该字段
interrupted = true;
}
} finally {
//如果线程被中断,或者出现异常时,failed=true,需要通过cancelAcquire()方法放弃获取锁
if (failed)
cancelAcquire(node);
}
}

doAcquireShared()方法的逻辑是,查看当前线程对应的节点的前置节点是不是头结点(即当前节点是头结点的下一个节点),如果是就尝试获取读锁,获取成功的话就将自己设置成头结点,然后根据中断标志进行处理;如果前置节点不是头结点,则进入shouldParkAfterFailedAcquire(),如果其前置节点是SIGNAL状态,就会在parkAndCheckInterrupt()方法中将当前线程挂起。doAcquireShared()与同在AQS中的另一个方法doAcquireSharedInterruptibly()非常相似,在CountDownLatch源码探究 (JDK 1.8)doAcquireSharedInterruptibly()有详细的解释,包括shouldParkAfterFailedAcquire()parkAndCheckInterrupt()这两个方法,有兴趣的话可以参考,本文不再讨论这些方法的细节。

3.写锁的加锁逻辑

介绍完读锁的加锁逻辑之后,接下来看看写锁加锁的实现原理:

    public void lock() {
sync.acquire(1);
} //加锁的代码与ReentrantLock一样,复用了AQS中的处理框架
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
} //ReentrantReadWriteLock的内部类Sync重写了tryAcquire()方法
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
//①如果w = 0,但是c!=0,说明读锁已结被占有,直接返回false表示获取写锁失败,说明即使线程自己持有读锁,也不能再次获取写锁
//②如果w != 0,说明写锁已经被占有,需要判断是不是当前线程占有写锁
if (w == 0 || current != getExclusiveOwnerThread())
return false;
//判断写锁的持有次数有没有超限,从这里可以知道写锁的最大重入次数是2^16-1
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//更新持有的写锁数量,这里可以看出写锁是可重入的
setState(c + acquires);
return true;
}
//走到这里,说明c=0,即当前读锁和写锁都没有被占有,公平锁会先检查队列中有没有其他线程在等待锁,非公平锁不会阻塞
//如果writerShouldBlock()反复返回false,才会考虑设置state字段,设置成功表示成功获取写锁,否则返回false表示获取写锁失败
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//记录当前线程持有写锁
setExclusiveOwnerThread(current);
return true;
}

公平写锁和非公平写锁同样都重写了writerShouldBlock()方法,非公平写锁的实现非常简单,直接返回false,表示非公平的写锁不需要阻塞;公平写锁会检查等待队列中是否有其他线程在等待获取锁,两种实现方式的源码如下:

    //公平写锁的实现
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
} //非公平写锁的实现
final boolean writerShouldBlock() {
return false; // writers can always barge
}

从上面的分析可以看出,线程在持有写锁的情况下,允许同时获取读锁,但是在线程持有读锁的情况下,不允许同时获取写锁,这个细节需要注意。

4.释放锁

  • 读锁释放锁
    public void unlock() {
sync.releaseShared(1);
} public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
} //当state=0时表示读锁已完全释放,才会返回true,其他情况返回false
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//判断当前线程是否是第一个获取读锁的线程
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
//firstReaderHoldCount=1,说明读锁只被当前线程占有1次,释放之后更新firstReader的值
if (firstReaderHoldCount == 1)
firstReader = null;
//如果当前线程多次持有读锁,则将计数减1
else
firstReaderHoldCount--;
} else {
//执行到这里,说明当前线程不是第一个获取读锁的线程
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
//如果当前线程获取的读锁次数<=1,在释放锁的时候,需要清除ThreadLocal中的记录
readHolds.remove();
//没有持有读锁的线程释放读锁会报错
if (count <= 0)
throw unmatchedUnlockException();
}
//将当前线程读锁的重入次数减1
--rh.count;
}
for (;;) {
int c = getState();
//更新读锁的值
int nextc = c - SHARED_UNIT;
//如果这一步CAS操作失败,就会自旋尝试,直到成功为止
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
//nextc=0,说明读锁已释放,返回true,否则返回false
//由于读锁是共享锁,可以有多个线程同时获取读锁,只有最后一个持有读锁的线程完全释放读锁,才会返回true
return nextc == 0;
}
} private void doReleaseShared() { for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
//头结点是SIGNAL状态时,将其状态更新成0,该操作会一直自旋重试,直到修改成功,成功之后会唤醒后面的等待线程
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
//如果头结点不是SIGNAL状态,就自旋将其更新为PROPAGATE状态
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
//h==head说明,从for循环开始到现在头结点没用发生变化
//注意:当线程释放锁的时候,会修改头结点
if (h == head) // loop if head changed
break;
}
} private void unparkSuccessor(Node node) { int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); Node s = node.next;
//waitStatus>0只有CANCELLED状态,代表节点放弃获取锁
if (s == null || s.waitStatus > 0) {
s = null;
//从队列尾部开始向前查找,目的是寻找node节点后第一个非CANCELLED状态的节点,并将s指向该节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//唤醒s节点对应的线程
if (s != null)
LockSupport.unpark(s.thread);
}

由于读锁是共享锁,并且是可重入锁,因此在最后一个持有读锁的线程最后一次释放读锁时,读锁才能真正被释放,此时才会通过doReleaseShared()方法唤醒队列中的等待线程。

  • 写锁释放锁
    public void unlock() {
sync.release(1);
} public final boolean release(int arg) {
//如果线程是释放写锁成功,则唤醒后面的等待线程
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
} protected final boolean tryRelease(int releases) {
//线程没有持有写锁不允许释放写锁
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
//释放写锁成功,则设置exclusiveOwnerThread=null,表示写锁目前没有被任何线程占有
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
} //判断当前线程是否持有写锁
protected final boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}

5.其他介绍

  • HoldCounter

    HoldCounter是用来记录线程持有读锁的数量,源码中使用cachedHoldCounter来记录最后一个获取读锁的是哪个线程,由于代码很简单,因此前文并未对其进行介绍,这里做一下简单讲解:
    /**
* A counter for per-thread read hold counts.
* Maintained as a ThreadLocal; cached in cachedHoldCounter
*/
static final class HoldCounter {
//记录线程持有读锁的次数
int count = 0;
// Use id, not reference, to avoid garbage retention
//记录线程id
final long tid = getThreadId(Thread.currentThread());
}

HoldCounter配合使用的是ThreadLocalHoldCounter类,使用readHolds字段维持对该类的引用,下面是ThreadLocalHoldCounter的源码:

    /**
* ThreadLocal subclass. Easiest to explicitly define for sake
* of deserialization mechanics.
*/
//继承了ThreadLocal
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}

ReentrantReadWriteLock源码探究的更多相关文章

  1. spring-cloud-sleuth+zipkin源码探究

    1. spring-cloud-sleuth+zipkin源码探究 1.1. 前言   粗略看了下spring cloud sleuth core源码,发现内容真的有点多,它支持了很多类型的链路追踪, ...

  2. spring-boot-2.0.3之quartz集成,数据源问题,源码探究

    前言 开心一刻 着火了,他报警说:119吗,我家发生火灾了. 119问:在哪里? 他说:在我家. 119问:具体点. 他说:在我家的厨房里. 119问:我说你现在的位置. 他说:我趴在桌子底下. 11 ...

  3. 【Java并发编程】16、ReentrantReadWriteLock源码分析

    一.前言 在分析了锁框架的其他类之后,下面进入锁框架中最后一个类ReentrantReadWriteLock的分析,它表示可重入读写锁,ReentrantReadWriteLock中包含了两种锁,读锁 ...

  4. Vue源码探究-全局API

    Vue源码探究-全局API 本篇代码位于vue/src/core/global-api/ Vue暴露了一些全局API来强化功能开发,API的使用示例官网上都有说明,无需多言.这里主要来看一下全局API ...

  5. Vue源码探究-事件系统

    Vue源码探究-事件系统 本篇代码位于vue/src/core/instance/events.js 紧跟着生命周期之后的就是继续初始化事件相关的属性和方法.整个事件系统的代码相对其他模块来说非常简短 ...

  6. Vue源码探究-状态初始化

    Vue源码探究-状态初始化 Vue源码探究-源码文件组织 Vue源码探究-虚拟DOM的渲染 本篇代码位于vue/src/core/instance/state.js 继续随着核心类的初始化展开探索其他 ...

  7. Vue源码探究-源码文件组织

    Vue源码探究-源码文件组织 源码探究基于最新开发分支,当前发布版本为v2.5.17-beta.0 Vue 2.0版本的大整改不仅在于使用功能上的优化和调整,整个代码库也发生了天翻地覆的重组.可见随着 ...

  8. SpringBoot读取配置文件源码探究

    1. SpringBoot读取配置文件源码探究 1.1. 概览 springboot的源码是再原来的Spring源码上又包了一层,看过spring源码都知道,当我们从入口debug进去的时候,原来的S ...

  9. ReentrantReadWriteLock 源码分析

    ReentrantReadWriteLock  源码分析: 1:数据结构: 成员变量: private final ReentrantReadWriteLock.ReadLock readerLock ...

随机推荐

  1. [Redis] Redis哨兵模式部署 - zz胖的博客

    1. 部署Redis集群 redis的安装及配置参考[redis部署] 本文以创建一主二从的集群为例. 1.1 部署与配置 先创建sentinel目录,在该目录下创建8000,8001,8002三个以 ...

  2. 吴裕雄--天生自然 人工智能机器学习实战代码:LASSO回归

    import numpy as np import matplotlib.pyplot as plt from sklearn import datasets, linear_model from s ...

  3. win7图片只显示图标不显示预览图解决方案

    问题描述: win7上图片只显示图标,不显示缩略图:不管是调节小图.中图还是大图或者其他均不显示:而且这种情况下使用截图工具截下来的图片都不自动带上扩展名:情况如下图: 解决方案: 打开计算机-> ...

  4. python Dom

    Dom(Document) 称为:文档对象模型,是一种用于HTML和XML文档的编程接口.它给文档提供了一种结构化的表示方法,可以改变文档的内容和呈现方式.DOM把网页和脚本以及其他的编程语言联系了起 ...

  5. HBase完全分布式集群搭建

    HBase完全分布式集群搭建 hbase和hadoop一样也分为单机版,伪分布式版和完全分布式集群版,此文介绍如何搭建完全分布式集群环境搭建.hbase依赖于hadoop环境,搭建habase之前首先 ...

  6. The Process class relies on proc_open, which is not available on your PHP installation报错解决

    同事laravel框架开发的代码有如下错误日志 production.ERROR: The Process class relies on proc_open, which is not availa ...

  7. Python---6条件判断与循环

    条件判断 计算机之所以能做很多自动化的任务,因为它可以自己做条件判断. 比如,输入用户年龄,根据年龄打印不同的内容,在Python程序中,用if语句实现: age = 20 if age >= ...

  8. 2019DDCTF部分Writeup

    -- re Windows Reverse1 通过DIE查壳发现存在upx,在linux上upx -d脱壳即可,拖入IDA,通过关键字符串找到关键函数: main函数中也没有什么,将输入的字符串带到s ...

  9. Azure Devops测试管理(上)

    因为最近测试人员合并到我这边开发组,对于如何能更好管理测试流程和测试与开发能更高效的完成任务,通俗的说如何能更敏捷,深入思考,然后就开始琢磨起TFS(也称之为VSTS/Azure Devops,因为我 ...

  10. 带你学习Javascript中的函数进阶(一)

    1. 函数的定义和调用 1.1 函数的定义方式 函数声明方式function关键字(命名函数) 函数表达式(匿名函数) new Function() var fn = new Function('参数 ...