A URL is a small string with a lot of structure inside it. https://shop.example.com:443/products/42?color=red#reviews is not one blob, it is six pieces glued together with separators, and any program that talks to the web needs to understand those pieces. Java's java.net.URL class is the standard way to parse and work with that structure. This lesson covers what a URL is at the spec level, how to construct a URL object from a string or its parts, how to read each component back out, the difference between URL and URI, and why URL.equals() quietly performs a DNS lookup that you almost never want.
A URL follows a fixed grammar defined by RFC 3986. Every URL is built from the same set of slots, each with its own separator.
The diagram lays out a single product detail URL into the five fields the URL class will parse for you. The separators between fields are part of the grammar, not part of any field value: the :// after the scheme, the : between host and port, the leading / on the path, the ? that starts the query string, and the # that starts the fragment. The class never strips a separator off and assigns it to a neighbor field.
A few real e-commerce URLs for the rest of the lesson:
| URL | What it represents |
|---|---|
https://shop.example.com/products/42 | A product detail page |
https://shop.example.com/checkout/return?orderId=A100&status=paid | A checkout callback with order id and status |
https://cdn.example.com/images/products/42/main.jpg | A product image hosted on a CDN |
https://shop.example.com/search?q=running+shoes&size=10 | A search query with two parameters |
https://api.example.com:8443/v1/orders/A100 | A versioned API endpoint on a non-default port |
Every one of these has a scheme, a host, an optional port, a path, an optional query, and an optional fragment. Java's URL class parses each into a separate accessor.
The java.net.URL class has three constructors you will use in practice. Each one starts with a different piece of input.
The first form, new URL(String), parses a complete URL out of a single string. Use this when you already have the full address.
The second form, new URL(URL context, String spec), resolves a relative reference against a base URL. The base is https://shop.example.com/products/, the relative piece is 42?color=red, and the result is a complete URL. This is the same algorithm a browser uses when you click a link on a page: the link's href is the spec, and the page's URL is the context. It is also how you build a list of product URLs given a catalog root: keep the base, append the product id.
The third form, new URL(protocol, host, port, file), takes the scheme, host, port, and file (path plus optional query) as separate arguments. Use this when you already have the pieces as separate variables and you do not want to combine them into a string just so the constructor can split them again. Pass -1 for the port if you want the default for the scheme.
Every new URL(...) parses the input string, even when the string came from another URL.toString() call. Cache URL objects when you build the same address many times in a loop.
MalformedURLExceptionThe URL constructors throw MalformedURLException when the input cannot be parsed. The two common triggers are an unknown scheme and a syntactically broken string.
The first failure is the most common one in practice. The class recognizes a fixed set of protocols (the standard ones plus any that have been registered as URL stream handlers), and a typo like htps is not on that list. The second failure is a string that has no :// at all, so the parser cannot find a scheme. The third call succeeds because ftp is one of the standard protocols. The class only checks that it knows how to handle the scheme, not that the host exists or that the resource is reachable.
MalformedURLException is a checked exception that extends IOException. Your call site has to either catch it or declare it. There is no URL.tryParse method; if you want a non-throwing parser, you have to wrap the call yourself or use URI.create.
Once you have a URL object, every component is one method call away. The getters mirror the slots in the diagram at the top of this lesson.
A few specific points to flag.
getPort() returns the explicit port from the string. If the URL did not include a port (most do not), getPort() returns -1. getDefaultPort() returns the standard port for the scheme: 80 for http, 443 for https, 21 for ftp, and -1 if the scheme has no default. A common pattern when opening a socket is to fall back: int port = url.getPort() != -1 ? url.getPort() : url.getDefaultPort();.
getPath() returns just the path piece, with a leading slash but no query and no fragment. getQuery() returns the query string with no leading ?. getRef() returns the fragment with no leading #. getFile() is a convenience method that concatenates path and query (with the ?) but excludes the fragment. The name "file" is historical; it does not mean a filesystem file, it means "the part the server uses to locate the resource."
getAuthority() returns the userinfo, host, and port together as one string (separated by : and @ as defined by the spec). getUserInfo() returns the userinfo segment, which is the user:password that can appear before the host in some URLs. For most modern URLs this is null.
Compare a URL with no explicit port against the same one with a port:
The -1 sentinel for "no port given" is easy to forget. If you write int port = url.getPort(); and feed it straight into a socket call, you will get an exception when the URL did not include an explicit port. Always check for -1 and fall back to getDefaultPort().
openStream()A URL is more than a parsed string. It also knows how to open a connection to the resource it points at, because Java ships with built-in handlers for http, https, file, and a few others. The simplest network call you can make is openStream(), which returns an InputStream you can read bytes from.
Output (first three lines of the response, abbreviated):
The openStream() call does a lot internally: it opens a TCP connection, sends an HTTP GET request with default headers, reads the status line and response headers, and hands you the response body as a stream. None of that detail is visible at the API level. You get bytes in, that is it.
This is fine for a one-line "fetch and read" script. It is not fine for anything you would ship in production. You cannot set request headers, you cannot change the HTTP method, you cannot read the status code, and you cannot send a request body.
openStream() blocks the calling thread until the connection completes and bytes arrive. There is no built-in timeout. If the server hangs, your thread hangs. For real applications, use HttpClient with an explicit Duration timeout.
A URL string is constrained by the RFC 3986 grammar. Spaces, &, =, ?, and a long list of other characters have reserved meanings or are not allowed at all. To put arbitrary user input into a URL, you have to encode it first. The URLEncoder and URLDecoder classes handle the conversion.
URLEncoder.encode replaces every unsafe character with either + (the space shortcut used in application/x-www-form-urlencoded data) or a % followed by the two hex digits of the byte value in UTF-8. The & in the user's query becomes %26, so the server will not mistake it for a parameter separator.
One gotcha: URLEncoder is designed for form-encoded query strings, not for full URL components. It encodes spaces as +, which is correct inside a query string but wrong inside a path. If you need to encode a path segment, prefer URI over URLEncoder, because URI follows the path-encoding rules from the spec.
What's wrong with this code?
The URL is built without encoding the user's input. The space and the & inside the query get passed through literally. The constructor does not complain (this particular shape happens to be permissive), but the server sees a query string with what looks like two parameters: q=running shoes and socks. The user's intent (find products matching the phrase "running shoes & socks") is lost.
Fix:
The encoded form is unambiguous. The server's URL parser sees one parameter q whose value, after decoding, is exactly what the user typed.
The URL class overrides equals() and hashCode(). So far so normal. The catch is what the override actually does.
The diagram shows the path a single equals call takes. The dangerous step is the red one: the implementation asks the operating system to resolve both hostnames to IP addresses and compares the resolved addresses, not the strings. Two URLs with different hostnames pointing at the same load balancer compare equal. Two URLs with the same hostname compare equal only after the DNS lookup completes.
This is bad for three reasons. First, every equals call costs a DNS round trip. Second, it can block for seconds if the DNS server is slow or unreachable. Third, the result depends on the network, so the same comparison can return different answers at different times.
hashCode() has the same problem. It resolves the host to compute the hash. Both methods are documented as performing this lookup, but the cost is invisible at the call site.
URL.equals() and URL.hashCode() perform blocking DNS resolution. Never put URL into a HashSet, HashMap key, or anything that calls hashCode repeatedly. Use URI or the URL's string form instead.
Here is a small program that shows the cost in real terms.
Output (your numbers will vary based on DNS speed):
A direct String.equals between the two URL strings would have taken microseconds. The reflection in this case took milliseconds because of the network round trip to the DNS server. Multiply this by a hot loop and you have a performance problem nobody can find from reading the code.
What's wrong with this code?
The code works, but every add and every contains triggers a DNS resolution on shop.example.com. A crawler that puts a million URLs into this set will fire a million DNS lookups. The hostname is the same for every entry, so the JVM and the OS cache the answer, but the lookup machinery still runs and the cache is not unlimited.
Fix:
URI.equals and URI.hashCode are pure string operations. No DNS, no blocking, no network dependency. For any data structure that hashes or compares URLs, store URI instead of URL.
URL vs URI: When to Use Whichjava.net.URI is the syntactic counterpart of URL. It parses the same grammar but does not know how to open connections. It also does not perform DNS lookups in equals or hashCode.
The diagram is the rule of thumb in four boxes. If you need to talk to the network, use URL. For everything else (storage, comparison, parsing, validation, building relative references), prefer URI. Converting between them is one method call in each direction.
URL.toURI() throws URISyntaxException if the URL string is not also valid URI syntax. URI.toURL() throws MalformedURLException if the URI has no scheme or has a scheme Java does not know how to handle. In practice both succeed for any well-formed https URL.
A second advantage of URI is that the URI.create(String) static factory throws an unchecked exception (IllegalArgumentException) instead of a checked one. That keeps parse-and-use code free of try blocks.
URI is stricter about syntax than URL. The space inside the path is a hard error for URI. URL would have accepted the same string in some JVMs because its parser is more forgiving. If you want a parser that rejects malformed input early, URI is the safer choice.
The newer HttpClient API in java.net.http accepts URI, not URL, in its builder. That alone is a strong signal that URI is the type to use in new code, and URL is appropriate only when you need to read or write over the network through the old URLConnection machinery.
Sometimes you have the host, path, and query as separate variables and you want to assemble a complete URL. There are three reasonable ways to do this, and they trade off readability against safety.
The first is plain string concatenation. It is the shortest, but you have to encode everything yourself.
The second is the four-argument URL constructor. It gives you scheme, host, port, and file as separate arguments. The file argument still has to be a well-formed path?query string, so the encoding burden is the same; you just do not have to write :// and : yourself.
The third is the multi-argument URI constructor, which encodes the components for you and then converts to a URL. This is the safest of the three for arbitrary input.
Note the difference in the encoded space: the URI constructor uses %20 (the path-style encoding) while URLEncoder used + (the form-style encoding). Both are valid for query strings in practice, but %20 is the safer choice if the value will end up in a path or fragment.
For e-commerce flows that build URLs from user-supplied data (search terms, filter values, coupon codes), the URI constructor is a good default. It removes the risk of forgetting to encode a single field. For URLs built from constants you control, plain concatenation is fine.
To pull the pieces together, here is a realistic example. After a checkout, the payment provider redirects the customer back to your site with the order id and status as query parameters. Your server reads the URL, validates it, and routes the customer to the right page.
Every piece you have seen so far shows up in this example. The constructor parses the string. getHost, getPath, and getQuery pull out the individual fields. The query is split by &, each key and value is decoded with URLDecoder (so thanks%21 becomes thanks!), and the resulting map drives a routing decision. A real server would also validate the host, sign the parameters with a secret, and check the order id against the database, but the URL plumbing is what we are showing here.
query.split("&") allocates a new String[] and runs a small regex compile. For high-volume request handlers, frameworks (or your own code) usually parse query strings once and cache the result on the request object.
10 quizzes