成为一名码农前,你应该知道的 10 件事

我知道在软件开发过程中,我不是唯一一个不知道如何开始而耽误软件开发周期的人。

软件开发过程中过多的资源提供了很多机会,但是也增加了第一步的选择难度。这里,对于那些想迈开第一步或正在迈开第一步的人,这里有 10 件事希望你们开始之前能够知道。

1. 选择一门开发语言很重要

软件开发行业有很多开发语言,它们各有自己的长处、短处,分别适用于不同的场景。

虽然你的第一语言几乎肯定是最难学的,而且许多最基本的基础知识在语言(变量、迭代等)之间是相似的,但是值得花时间和精力去做一些研究,并决定你想学习哪种开发语言。

有时候,开发语言的易用性也应该认真考虑下。如果选择一门比较复杂难学的语言会让你感到恐惧,阻碍你进步,那你可以一开始选择一门自己能驾驭的开发语言,这样更容易成功。

另一个关键因素是你为什么学习软件开发。如果你想开发一款 App,那你最好提前开始规划,并研究下哪种开发语言和技术最适合你的需求。

如果你希望以软件工程师的身份加入一家公司,那么列举一下你想加入的 10-20 家公司的名单,找到他们的软件开发岗位上的职位,并记下他们使用的语言,然后学习对应的开发语言。

2. 开发语言的官方帮助文档

开发语言不仅仅是为了理解而创造的,从最基本的概念到最复杂的应用程序,开发语言的语法、框架和类库都在网上有详细的文档记录。我已经列出了以下几个链接,这样您就可以看到这些链接是什么样子的:

Python.org

RubyonRails.org

Reactjs.org

3. Google 是你的好伙伴

每一个软件工程师在遇到困难时都会使用谷歌。这不是像初学者那样学习官方指导文档,而是在实际的工作中,你所遇到的奇怪的问题或错误,已经在 StackOverflow.com 或其他类似网站上有人已经反馈并被解决了。通过 Google 你可以快速解决问题。

4. 这是解决问题的方法,而且可以学习

很多人看 Web 应用程序,觉得它们只是魔术。

事实上他们不是,它们是由精确编写的成百上千行的代码组成的,执行各个函数,并在整个系统中完美地运行。

任何东西或语言都是可以学习的。一开始这毫无意义,就像法语或汉语一开始对非母语者毫无意义一样。但是就像这些语言一样,你一点一点地学会如何用它的语法和措辞来表达你的信息,并学会通过它来传达你想要的任何东西。

5. 你不会知道一切知识

你永远不会知道关于软件工程或者一门开发语言的所有知识。

这是因为有太多的知识需要知道,同时这些知识也在不断的发生变化。

新版本的语言定期发布,具有不同的功能和更新。新的框架和库的出现,每个人都想使用它。

软件工程的唯一不变之处是它总是在变化和发展,你最好能马上适应这个事实。

6. GitHub

GitHub 是一个远程版本控制系统,它与本地版本控制系统 Git 配合工作。

你应该从一开始就有一个 GitHub 帐户,并且学会很熟练的使用 GitHubGit

在项目的开发中,你可能经常遇到这样的场景:“需要修复一个问题,但是还不是很确定如何做,只是去尝试”。这时你就可以使用 Git 管理你的项目。

Git 允许您在不破坏工作代码的情况下做一些开发任务。它允许您从主项目分支创建单独的分支去工作,本质上创建一个单独的代码版本。您可以尝试这种和那种解决问题的方法,文件将被保存,但是在您真正执行 git 提交之前,它不会保存在主分支中。

GitHub 只是本地 Git 存储库的远程存储系统(这也意味着,对于每个项目,您都需要一个本地 Git repo 和一个 GitHub repo)。把它想象成代码的 DropBox。这样,如果你的电脑出了什么事,你写的所有宝贵的代码都不会丢失。

7. HackerRank、LeetCode 和 Codewars

这三个网站(当然还有很多别的网站)有很多编码挑战的题目,供你打磨提高自己的编程技能。

当你能够轻松编写一些基础代码之后,你可以尝试选择上面三个网站中的一个来学习用编程解决问题。通过锻炼不仅能够提高你对开发语言的语法掌握,还能提高你解决问题的能力。

我个人喜欢 LeetCode,因为它能给你关于解决方案质量的真实反馈,而不仅仅是你是否解决了它。不过,我也经常使用 HackerRank,因为我喜欢它提供的挑战。

8. 熟能生巧

你永远不会完美,也永远不会知道一切。但是我怎么强调练习的重要性都不过分。

和其他人一样,我也曾有过这样的感觉:我在努力学习一些根本看不出用途的东西。它会让你感到士气低落,让你想放弃,继续下一个。但我保证,如果你坚持下去——继续练习,即使你觉得自己已经一事无成了——最终你会有那一刻,它确实会响起来。突然间一切都会有意义的。去那里可能很难,但这是值得的。

9. 语言与框架不同,框架与类库不同

常会在一些相似场景下见到开发语言、框架和类库三个名词,但是他们不是一回事。

开发语言是除了二进制外比较接近底层的编程,比如常见的开发语言:RubyJavaPythonJavaScript

类库和框架是建立在开发语言之上的产物,但是他们俩也不是同一种东西。他们都是为了解决通用型问题,使用开发语言抽象开发而来。它们通常是以提供通用的接口方法的形式出现,这样就不必每次都编写这些代码,也不必去生成新的应用程序框架。

使用类库时,软件工程师决定他或她希望在何处使用类库,他或她控制着申请的流程。类库的例子有 React.jsRedux,它们都是 JavaScript 库。使用一个框架,可以让应用程序的流程是预先确定的,比如 Rails 是一个通用的 Ruby 框架, Django 是一个 Python 框架。

10. 比语法更重要的事情

软件工程不仅仅是记忆和理解语法。

当代码按预期工作时,会觉得一切都是很容易的。“当你确信你写的代码是正确的时候,你就已经知道如何调试了”。调试在解决一个复杂的问题时是充满挑战和乐趣的。要做到这一点,你不仅要明白 a+b=c,还要明白为什么 a+b=c。如果你不明白,总有一天你会把 ab 加起来,得到 d,却不知道为什么,也没有工具来找出原因。

在软件开发中,每种开发语言的细微差别和执行一行代码的工作方式是如此重要,因为有时会导致意想不到的后果。


作者:邓士伟
链接:https://juejin.im/post/5e15eb98f265da5d112579f9
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
codeceo 首页问答热门文章RSS订阅 文章首页 Java JavaScript PHP iOS Android HTML5 CSS3 Linux C++ Python C# Node.Js 一文让你彻底理解 Java HashMap 和 ConcurrentHashMap 2018-07-25 分类:JAVA开发、编程开发、首页精华0人评论 来源:crossoverjie.top 分享到:更多0 言 Map 这样的 Key Value 在软开发中是非常经典的结构,常用于在内存中存放数据。 本篇主要想讨论 ConcurrentHashMap 这样一个并发容器,在正式开始之我觉得有必要谈谈 HashMap,没有它就不会有后面的 ConcurrentHashMap。 HashMap 众所周知 HashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。 Base 1.7 1.7 中的数据结构图: 先来看看 1.7 中的实现。 这是 HashMap 中比较核心的几个成员变量;看看分别是什么意思? 初始化桶大小,因为底层是数组,所以这是数组默认的大小。 桶最大值。 默认的负载因子(0.75) table 真正存放数据的数组。 Map 存放数量的大小。 桶大小,可在初始化时显式指定。 负载因子,可在初始化时显式指定。 重点解释下负载因子: 由于给定的 HashMap 的容量大小是固定的,比如默认初始化: public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; threshold = initialCapacity; init(); } 给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。 因此通常建议能提预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。 根据代码可以看到其实真正存放数据的是 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; 这个数组,那么它又是如何定义的呢? Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出: key 就是写入时的键。 value 自然就是值。 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。 hash 存放的是当 key 的 hashcode。 知晓了基本结构,那来看看其中重要的写入、获取函数: put 方法 public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null; } 判断当数组是否需要初始化。 如果 key 为空,则 put 一个空值进去。 根据 key 计算出 hashcode。 根据计算出的 hashcode 定位出所在桶。 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。 如果桶是空的,说明当位置没有数据存入;新增一个 Entry 对象写入当位置。 void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; } 当调用 addEntry 写入 Entry 时需要判断是否需要扩容。 如果需要就进行两倍扩充,并将当的 key 重新 hash 并定位。 而在 createEntry 中会将当位置的桶传入到新建的桶中,如果当桶有值就会在位置形成链表。 get 方法 再来看看 get 函数: public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; } 首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。 判断该位置是否为链表。 不是链表就根据 key、key 的 hashcode 是否相等来返回值。 为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。 啥都没取到就直接返回 null 。 Base 1.8 不知道 1.7 的实现大家看出需要优化的点没有? 其实一个很明显的地方就是: 当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。 因此 1.8 中重点优化了这个查询效率。 1.8 HashMap 结构图: 先来看看几个核心的成员变量: static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int TREEIFY_THRESHOLD = 8; transient Node<K,V>[] table; /** * Holds cached entrySet(). Note that AbstractMap fields are used * for keySet() and values(). */ transient Set<Map.Entry<K,V>> entrySet; /** * The number of key-value mappings contained in this map. */ transient int size; 和 1.7 大体上都差不多,还是有几个重要的区别: TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。 HashEntry 修改为 Node。 Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。 再来看看核心方法。 put 方法 看似要比 1.7 的复杂,我们一步步拆解: 判断当桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。 根据当 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当位置创建一个新桶即可。 如果当桶有值( Hash 冲突),那么就要比较当桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。 如果当桶为红黑树,那就要按照红黑树的方式写入数据。 如果是个链表,就需要将当的 key、value 封装成一个新节点写入到当桶的后面(形成链表)。 接着判断当链表的大小是否大于预设的阈值,大于时就要转换为红黑树。 如果在遍历过程中找到 key 相同时直接退出遍历。 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。 最后判断是否需要进行扩容。 get 方法 public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } get 方法看起来就要简单许多了。 首先将 key hash 之后取得所定位的桶。 如果桶为空则直接返回 null 。 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。 如果第一个不匹配,则判断它的下一个是红黑树还是链表。 红黑树就按照树的查找方式返回值。 不然就按照链表的方式遍历匹配返回值。 从这两个核心方法(get/put)可以看出 1.8 中对大链表了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。 但是 HashMap 原有的问题也都存在,比如在并发场景下使用时容易出现死循环。 final HashMap<String, String> map = new HashMap<String, String>(); for (int i = 0; i < 1000; i++) { new Thread(new Runnable() { @Override public void run() { map.put(UUID.randomUUID().toString(), ""); } }).start(); } 但是为什么呢?简单分析下。 看过上文的还记得在 HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。 如下图: 遍历方式 还有一个值得注意的是 HashMap 的遍历方式,通常有以下几种: Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator(); while (entryIterator.hasNext()) { Map.Entry<String, Integer> next = entryIterator.next(); System.out.println("key=" + next.getKey() + " value=" + next.getValue()); } Iterator<String> iterator = map.keySet().iterator(); while (iterator.hasNext()){ String key = iterator.next(); System.out.println("key=" + key + " value=" + map.get(key)); } 强烈建议使用第一种 EntrySet 进行遍历。 第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。 简单总结下 HashMap:无论是 1.7 还是 1.8 其实都能看出 JDK 没有对它任何的同步操作,所以并发会出问题,甚至 1.7 中出现死循环导致系统不可用(1.8 已经修复死循环问题)。 因此 JDK 推出了专项专用的 ConcurrentHashMap ,该类位于 java.util.concurrent 包下,专门用于解决并发问题。 坚持看到这里的朋友算是已经把 ConcurrentHashMap 的基础已经打牢了,下面正式开始分析。 ConcurrentHashMap ConcurrentHashMap 同样也分为 1.7 、1.8 版,两者在实现上略有不同。 Base 1.7 先来看看 1.7 的实现,下面是他的结构图: 如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。 它的核心成员变量: /** * Segment 数组,存放数据时首先需要定位到具体的 Segment 中。 */ final Segment<K,V>[] segments; transient Set<K> keySet; transient Set<Map.Entry<K,V>> entrySet; Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下: static final class Segment<K,V> extends ReentrantLock implements Serializable { private static final long serialVersionUID = 2249069246763182397L; // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶 transient volatile HashEntry<K,V>[] table; transient int count; transient int modCount; transient int threshold; final float loadFactor; } 看看其中 HashEntry 的组成: 和 HashMap 非常类似,唯一的区别就是其中的核心数据如 value ,以及链表都是 Volatile 修饰的,保证了获取时的可见性。 原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。 下面也来看看核心的 put get 方法。 put 方法 public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException(); int hash = hash(key); int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment s = ensureSegment(j); return s.put(key, hash, value, false); } 首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。 final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) { K k; if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; } 虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。 首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。 尝试自旋获取锁。 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。 再结合图看看 put 的流程。 将当 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。 遍历该 HashEntry,如果不为空则判断传入的 key 和当遍历的 key 是否相等,相等则覆盖旧的 value。 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。 最后会解除在 1 中所获取当 Segment 的锁。 get 方法 public V get(Object key) { Segment<K,V> s; // manually integrate access methods to reduce overhead HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; } get 逻辑比较简单: 只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。 由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。 ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。 Base 1.8 1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题。 那就是查询遍历链表效率太低。 因此 1.8 了一些数据结构上的调整。 首先来看下底层的组成结构: 看起来是不是和 1.8 HashMap 结构类似? 其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。 也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。 其中的 val next 都用了 volatile 修饰,保证了可见性。 put 方法 重点来看看 put 函数: 根据 key 计算出 hashcode 。 判断是否需要进行初始化。 f 即为当 key 定位出的 Node,如果为空表示当位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。 如果当位置的 hashcode == MOVED == -1,则需要进行扩容。 如果都不满足,则利用 synchronized 锁写入数据。 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。 get 方法 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。 如果是红黑树那就按照树的方式获取值。 就不满足那就按照链表的方式遍历获取值。 1.8 在 1.7 的数据结构上了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。 总结 看完了整个 HashMap 和 ConcurrentHashMap 在 1.7 和 1.8 中不同的实现方式相信大家对他们的理解应该会更加到位。 其实这块也是面试的重点内容,通常的套路是: 谈谈你理解的 HashMap,讲讲其中的 get put 过程。 1.8 了什么优化? 是线程安全的嘛? 不安全会导致哪些问题? 如何解决?有没有线程安全的并发容器? ConcurrentHashMap 是如何实现的? 1.7、1.8 实现有何不同?为什么这么? 这一串问题相信大家仔细看完都能怼回面试官。 除了面试会问到之外平时的应用其实也蛮多,像之谈到的 Guava 中 Cache 的实现就是利用 ConcurrentHashMap 的思想。 同时也能学习 JDK 作者大牛们的优化思路以及并发解决方案。 其实写这篇的提是源于 GitHub 上的一个 Issues,也希望大家能参与进来,共同维护好这个项目。 分享到:更多0 继续浏览有关ConcurrentHashmapHashMapJAVA开发的文章 发表我的评论 表情插代码发布评论有人回复时邮通知我 热门文章 成为伟大程序员的 10 个要点 如何成为一名成功的程序员 25个最基本的JavaScript面试问题及答案 程序员最核心的竞争力是什么? Java 内存模型 JMM 浅析 理解 Flexbox:你需要知道的一切 创造型职业程序员的无奈 我(作为一名开发者)所犯过的错误 黑客老王:一个人的黑客史 阿里面试回来,想和Java程序员谈一谈 职场人生 软工程师生存指南:面试准备、工作经验和实用工具 自由职业的这两年 倾听程序员的心声真的很重要 平庸开发者的生存指南 为什么我从 Google 辞职而为自己工作 我是一名朝九晚五的程序员(你也可以!) 成为伟大程序员的 10 个要点 如何处理任程序员留下的代码 开发人员爱开发 如何成为一名成功的程序员 版权所有,保留一切权利! © 2016 码农网 浙ICP备14003773号-1 浙公网安备 33010502000955号 商务合作QQ:290074886(请注明来意)

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值